microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v3.3.41

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Test-SHAStaleness.ps1

828lines · modepreview

#!/usr/bin/env pwsh
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: MIT
#Requires -Version 7.0

<#
.SYNOPSIS
    Monitors SHA-pinned dependencies for staleness and security vulnerabilities.

.DESCRIPTION
    This script scans all SHA-pinned dependencies across GitHub Actions workflows
    to identify stale or potentially vulnerable dependencies. It outputs results in structured formats
    that can be consumed by CI/CD systems to generate build warnings.

    Key features:
    - Detects outdated GitHub Actions SHAs
    - Outputs results for CI/CD integration
    - Supports multiple output formats (JSON, Azure DevOps, GitHub Actions)

.PARAMETER OutputFormat
    Output format: 'json', 'azdo', 'github', or 'console' (default: console)

.PARAMETER MaxAge
    Maximum age in days before considering a dependency stale (default: 30)

.PARAMETER LogPath
    Path for security logging (default: ./logs/sha-staleness-monitoring.log)

.PARAMETER OutputPath
    Path to write structured output file (default: ./logs/stale-dependencies.json)

.EXAMPLE
    ./Test-SHAStaleness.ps1 -OutputFormat github
    Check for stale SHAs and output GitHub Actions warnings

.EXAMPLE
    ./Test-SHAStaleness.ps1 -OutputFormat azdo -MaxAge 14
    Check for stale SHAs and output Azure DevOps warnings for dependencies older than 14 days

.EXAMPLE
    ./Test-SHAStaleness.ps1 -OutputFormat json -OutputPath ./security-report.json
    Generate JSON report of all stale dependencies

.EXAMPLE
    ./Test-SHAStaleness.ps1 -FailOnStale
    Fail the build if stale dependencies are found

.EXAMPLE
    ./Test-SHAStaleness.ps1 -GraphQLBatchSize 10
    Use smaller GraphQL batch size for rate-limited environments
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")]
    [string]$OutputFormat = "console",

    [Parameter(Mandatory = $false)]
    [int]$MaxAge = 30,

    [Parameter(Mandatory = $false)]
    [string]$LogPath = "./logs/sha-staleness-monitoring.log",

    [Parameter(Mandatory = $false)]
    [string]$OutputPath = "./logs/sha-staleness-results.json",

    [Parameter(Mandatory = $false)]
    [switch]$FailOnStale,

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, 50)]
    [int]$GraphQLBatchSize = 20
)

$ErrorActionPreference = 'Stop'

# Import CIHelpers for workflow command escaping
Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force
Import-Module (Join-Path $PSScriptRoot 'Modules/SecurityHelpers.psm1') -Force

# Route Write-SecurityLog output through script-scoped format and log path
$PSDefaultParameterValues['Write-SecurityLog:OutputFormat'] = $OutputFormat
$PSDefaultParameterValues['Write-SecurityLog:LogPath'] = $LogPath

# Script-scope collection of stale dependencies (used by multiple functions)
$script:StaleDependencies = [System.Collections.Generic.List[PSCustomObject]]::new()

function Get-BulkGitHubActionsStaleness {
    param(
        [Parameter(Mandatory = $true)]
        [array]$ActionRepos,

        [Parameter(Mandatory = $true)]
        [hashtable]$ShaToActionMap,

        [int]$BatchSize = 20
    )

    # Setup headers with authentication
    $headers = @{
        "Content-Type" = "application/json"
    }

    # Check multiple potential sources for GitHub token
    $githubToken = $null
    if ($env:GITHUB_TOKEN) {
        $githubToken = $env:GITHUB_TOKEN
    }
    elseif ($env:SYSTEM_ACCESSTOKEN -and $env:BUILD_REPOSITORY_PROVIDER -eq "GitHub") {
        $githubToken = $env:SYSTEM_ACCESSTOKEN
    }
    elseif ($env:GH_TOKEN) {
        $githubToken = $env:GH_TOKEN
    }

    # Validate token if provided
    $tokenStatus = Test-GitHubToken -Token $githubToken
    if ($tokenStatus.Valid) {
        $headers['Authorization'] = "Bearer $githubToken"
    }
    elseif ($githubToken) {
        Write-SecurityLog "Token validation failed, proceeding without authentication" -Level Warning
    }

    $apiBase = Get-GitHubApiBase

    # Build GraphQL query for multiple repositories (batch 1: get default branches)
    $repoQueries = @()
    $aliasMap = @{}

    foreach ($i in 0..($ActionRepos.Count - 1)) {
        $repo = $ActionRepos[$i]
        $alias = "repo$i"
        $aliasMap[$alias] = $repo

        # Parse owner/repo (handle actions with subpaths like github/codeql-action/upload-sarif)
        $parts = $repo.Split('/')
        if ($parts.Count -lt 2) { continue }
        $owner = $parts[0]
        $repoName = $parts[1]

        $repoQueries += @"
        $alias`: repository(owner: "$owner", name: "$repoName") {
            name
            defaultBranchRef {
                target {
                    ... on Commit {
                        oid
                        committedDate
                    }
                }
            }
        }
"@
    }

    # Single GraphQL query for all repository default branches
    $graphqlQuery = @{
        query = @"
        query {
            $($repoQueries -join "`n            ")
            rateLimit {
                limit
                remaining
                used
                resetAt
            }
        }
"@
    } | ConvertTo-Json -Depth 10

    try {
        $repoResponse = Invoke-GitHubAPIWithRetry -Uri "$apiBase/graphql" -Method POST -Headers $headers -Body $graphqlQuery
        if ($null -eq $repoResponse) { throw "GitHub GraphQL API returned no response" }

        Write-SecurityLog "GraphQL Rate Limit: $($repoResponse.data.rateLimit.remaining)/$($repoResponse.data.rateLimit.limit) remaining" -Level Info

        if ($repoResponse.errors) {
            Write-SecurityLog "GraphQL errors: $($repoResponse.errors | ConvertTo-Json)" -Level Warning
        }
    }
    catch {
        $statusCode = $null
        if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
            $statusCode = [int]$_.Exception.Response.StatusCode
        }

        if ($statusCode -in 403, 429) {
            Write-SecurityLog "Repository GraphQL query hit rate limit ($statusCode). Falling back to REST checks." -Level Warning
            Write-SecurityLog "SOLUTION: Provide a GitHub token via GITHUB_TOKEN environment variable for higher rate limits" -Level Warning
        }
        else {
            Write-SecurityLog "Repository GraphQL query failed: $($_.Exception.Message)" -Level Error
            Write-SecurityLog "CAUSE: Network connectivity issue or GitHub API unavailable" -Level Warning
        }

        throw
    }

    # Collect commit queries for all current SHAs
    $commitQueries = @()
    $commitAliasMap = @{}
    $commitIndex = 0

    foreach ($key in $ShaToActionMap.Keys) {
        $action = $ShaToActionMap[$key]
        $alias = "commit$commitIndex"
        $commitAliasMap[$alias] = $key

        # Parse owner/repo (handle actions with subpaths like github/codeql-action/upload-sarif)
        $parts = $action.Repo.Split('/')
        if ($parts.Count -lt 2) {
            Write-SecurityLog "Invalid action repository format: $($action.Repo) - must be 'owner/repo'" -Level Warning
            Write-SecurityLog "SOLUTION: Verify action reference in workflow file follows correct format" -Level Warning
            continue
        }
        $owner = $parts[0]
        $repoName = $parts[1]

        $commitQueries += @"
        $alias`: repository(owner: "$owner", name: "$repoName") {
            object(oid: "$($action.SHA)") {
                ... on Commit {
                    oid
                    committedDate
                }
            }
        }
"@
        $commitIndex++
    }

    # Use configurable batch size from script parameter
    $allCommitResults = @{}

    for ($i = 0; $i -lt $commitQueries.Count; $i += $BatchSize) {
        $endIndex = [Math]::Min($i + $BatchSize - 1, $commitQueries.Count - 1)
        $batchQueries = $commitQueries[$i..$endIndex]

        $commitGraphqlQuery = @{
            query = @"
            query {
                $($batchQueries -join "`n                ")
                rateLimit {
                    remaining
                    cost
                }
            }
"@
        } | ConvertTo-Json -Depth 10

        try {
            $commitResponse = Invoke-GitHubAPIWithRetry -Uri "$apiBase/graphql" -Method POST -Headers $headers -Body $commitGraphqlQuery
            if ($null -eq $commitResponse) {
                Write-SecurityLog "GitHub GraphQL API returned no response for commit batch query" -Level Warning
                continue
            }

            # Merge results
            foreach ($property in $commitResponse.data.PSObject.Properties) {
                if ($property.Name -ne "rateLimit") {
                    $allCommitResults[$property.Name] = $property.Value
                }
            }

            Write-SecurityLog "GraphQL batch $([Math]::Floor($i / $BatchSize) + 1): Cost $($commitResponse.data.rateLimit.cost), $($commitResponse.data.rateLimit.remaining) remaining" -Level Info
        }
        catch {
            Write-SecurityLog "Commit GraphQL batch query failed: $($_.Exception.Message)" -Level Warning
            Write-SecurityLog "CAUSE: Network connectivity issue, rate limit exhausted, or malformed query" -Level Warning
            Write-SecurityLog "SOLUTION: Check GitHub API status or reduce -GraphQLBatchSize parameter (current: $BatchSize)" -Level Warning
        }
    }

    # Process results and return staleness information
    $results = @()

    foreach ($key in $ShaToActionMap.Keys) {
        $action = $ShaToActionMap[$key]

        # Find repository data
        $repoAlias = $null
        for ($i = 0; $i -lt $ActionRepos.Count; $i++) {
            if ($ActionRepos[$i] -eq $action.Repo) {
                $repoAlias = "repo$i"
                break
            }
        }

        if (-not $repoAlias -or -not $repoResponse.data.$repoAlias) {
            Write-SecurityLog "No repository data found for $($action.Repo)" -Level Warning
            continue
        }

        $repoData = $repoResponse.data.$repoAlias
        if (-not $repoData.defaultBranchRef) {
            Write-SecurityLog "No default branch found for $($action.Repo)" -Level Warning
            continue
        }

        $latestSHA = $repoData.defaultBranchRef.target.oid
        $latestDate = [DateTime]::Parse($repoData.defaultBranchRef.target.committedDate)

        # Find current commit data
        $commitAlias = $null
        foreach ($alias in $commitAliasMap.Keys) {
            if ($commitAliasMap[$alias] -eq $key) {
                $commitAlias = $alias
                break
            }
        }

        if ($commitAlias -and $allCommitResults[$commitAlias] -and $allCommitResults[$commitAlias].object) {
            $currentCommit = $allCommitResults[$commitAlias].object
            $currentDate = [DateTime]::Parse($currentCommit.committedDate)
            $daysOld = [Math]::Round((Get-Date).Subtract($currentDate).TotalDays)

            $results += @{
                ActionRepo  = $action.Repo
                CurrentSHA  = $action.SHA
                LatestSHA   = $latestSHA
                CurrentDate = $currentDate
                LatestDate  = $latestDate
                DaysOld     = $daysOld
                IsStale     = $action.SHA -ne $latestSHA -and $daysOld -gt $MaxAge
                File        = $action.File
            }
        }
        else {
            Write-SecurityLog "No commit data found for $($action.Repo)@$($action.SHA)" -Level Warning
        }
    }

    $totalCalls = 1 + [Math]::Ceiling($commitQueries.Count / $BatchSize)
    $originalCalls = $ShaToActionMap.Count * 3
    $reduction = [Math]::Round((1 - ($totalCalls / $originalCalls)) * 100, 1)

    Write-SecurityLog "GraphQL optimization: Reduced from ~$originalCalls REST calls to $totalCalls GraphQL calls ($reduction% reduction)" -Level Success

    return $results
}

function Test-GitHubActionsForStaleness {
    Write-SecurityLog "Scanning GitHub Actions workflows for stale SHAs..." -Level Info

    $WorkflowFiles = Get-ChildItem -Path ".github/workflows" -Filter "*.yml" -ErrorAction SilentlyContinue
    $allActionRepos = @()
    $shaToActionMap = @{}

    # First pass: collect all unique repositories and SHAs
    foreach ($File in $WorkflowFiles) {
        $Content = Get-Content -Path $File.FullName -Raw
        $SHAMatches = [regex]::Matches($Content, "uses:\s*([^@\s]+)@([a-fA-F0-9]{40})")

        foreach ($Match in $SHAMatches) {
            $ActionRepo = $Match.Groups[1].Value
            $CurrentSHA = $Match.Groups[2].Value

            if ($ActionRepo -notin $allActionRepos) {
                $allActionRepos += $ActionRepo
            }

            $shaToActionMap["$ActionRepo@$CurrentSHA"] = @{
                Repo = $ActionRepo
                SHA  = $CurrentSHA
                File = $File.FullName
            }
        }
    }

    if (@($allActionRepos).Count -eq 0) {
        Write-SecurityLog "No SHA-pinned GitHub Actions found" -Level Info
        return
    }

    Write-SecurityLog "Found $(@($allActionRepos).Count) unique repositories with $(@($shaToActionMap.Keys).Count) SHA-pinned actions" -Level Info

    # Bulk query for all actions using GraphQL optimization
    try {
        $bulkResults = Get-BulkGitHubActionsStaleness -ActionRepos $allActionRepos -ShaToActionMap $shaToActionMap -BatchSize $GraphQLBatchSize

        foreach ($result in $bulkResults) {
            if ($result.IsStale) {
                $script:StaleDependencies.Add([PSCustomObject]@{
                    Type           = "GitHubAction"
                    File           = $result.File
                    Name           = $result.ActionRepo
                    CurrentVersion = $result.CurrentSHA
                    LatestVersion  = $result.LatestSHA
                    DaysOld        = $result.DaysOld
                    Severity       = if ($result.DaysOld -gt 90) { "High" } elseif ($result.DaysOld -gt 60) { "Medium" } else { "Low" }
                    Message        = "GitHub Action is $($result.DaysOld) days old (current: $($result.CurrentSHA.Substring(0,8)), latest: $($result.LatestSHA.Substring(0,8)))"
                })

                Write-SecurityLog "Found stale GitHub Action: $($result.ActionRepo) ($($result.DaysOld) days old)" -Level Warning
            }
            else {
                Write-SecurityLog "GitHub Action is up-to-date: $($result.ActionRepo)" -Level Info
            }
        }
    }
    catch {
        Write-SecurityLog "Bulk GraphQL check failed, falling back to individual checks: $($_.Exception.Message)" -Level Warning

        # Fallback to individual REST API calls via Invoke-GitHubAPIWithRetry
        $defaultBranchCache = @{}
        foreach ($key in $shaToActionMap.Keys) {
            $action = $shaToActionMap[$key]

            Write-SecurityLog "Checking GitHub Action (fallback): $($action.Repo)@$($action.SHA)" -Level Info

            $headers = @{}
            if ($env:GITHUB_TOKEN) {
                $headers['Authorization'] = "token $env:GITHUB_TOKEN"
            }

            $apiBase = Get-GitHubApiBase
            $repoSegments = $action.Repo.Split('/')
            if ($repoSegments.Count -lt 2) {
                Write-SecurityLog "Invalid GitHub Action repository format: $($action.Repo)" -Level Warning
                continue
            }

            $owner = $repoSegments[0]
            $repoName = $repoSegments[1]
            $repoLookup = "$owner/$repoName"

            if (-not $defaultBranchCache.ContainsKey($repoLookup)) {
                $repoInfo = Invoke-GitHubAPIWithRetry -Uri "$apiBase/repos/$repoLookup" -Method GET -Headers $headers
                if ($repoInfo) {
                    $defaultBranchCache[$repoLookup] = if ($repoInfo.default_branch) { $repoInfo.default_branch } else { "main" }
                }
                else {
                    Write-SecurityLog "Failed to discover default branch for $repoLookup, defaulting to 'main'" -Level Warning
                    $defaultBranchCache[$repoLookup] = "main"
                }
            }

            $branchName = $defaultBranchCache[$repoLookup]

            $BranchInfo = Invoke-GitHubAPIWithRetry -Uri "$apiBase/repos/$repoLookup/branches/$branchName" -Method GET -Headers $headers
            if (-not $BranchInfo) {
                Write-SecurityLog "Failed to check GitHub Action $($action.Repo): could not fetch branch info" -Level Warning
                continue
            }
            $LatestSHA = $BranchInfo.commit.sha

            if ($action.SHA -ne $LatestSHA) {
                $CurrentCommit = Invoke-GitHubAPIWithRetry -Uri "$apiBase/repos/$repoLookup/commits/$($action.SHA)" -Method GET -Headers $headers
                if (-not $CurrentCommit) {
                    Write-SecurityLog "Failed to check GitHub Action $($action.Repo): could not fetch commit info" -Level Warning
                    continue
                }
                $CurrentDate = [DateTime]::Parse($CurrentCommit.commit.author.date)
                $DaysOld = [Math]::Round((Get-Date).Subtract($CurrentDate).TotalDays)

                if ($DaysOld -gt $MaxAge) {
                    $script:StaleDependencies.Add([PSCustomObject]@{
                        Type           = "GitHubAction"
                        File           = $action.File
                        Name           = $action.Repo
                        CurrentVersion = $action.SHA
                        LatestVersion  = $LatestSHA
                        DaysOld        = $DaysOld
                        Severity       = if ($DaysOld -gt 90) { "High" } elseif ($DaysOld -gt 60) { "Medium" } else { "Low" }
                        Message        = "GitHub Action is $DaysOld days old (current: $($action.SHA.Substring(0,8)), latest: $($LatestSHA.Substring(0,8)))"
                    })

                    Write-SecurityLog "Found stale GitHub Action (fallback): $($action.Repo) ($DaysOld days old)" -Level Warning
                }
            }
        }
    }
}

function Write-SecurityOutput {
    param(
        [Parameter(Mandatory = $false)]
        [array]$Dependencies = @(),

        [Parameter(Mandatory)]
        [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")]
        [string]$OutputFormat,

        [Parameter()]
        [string]$OutputPath
    )

    switch ($OutputFormat) {
        "json" {
            $JsonOutput = @{
                Timestamp       = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"
                MaxAgeThreshold = $MaxAge
                TotalStaleItems = @($Dependencies).Count
                Dependencies    = $Dependencies
            } | ConvertTo-Json -Depth 10

            try {
                $OutputDir = Split-Path -Parent $OutputPath
                if (!(Test-Path $OutputDir)) {
                    New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
                    Write-SecurityLog "Created output directory: $OutputDir" -Level Info
                }

                Set-Content -Path $OutputPath -Value $JsonOutput
                Write-SecurityLog "JSON report written to: $OutputPath" -Level Success
            }
            catch {
                Write-SecurityLog "Failed to write JSON output: $($_.Exception.Message)" -Level Error
                Write-SecurityLog "CAUSE: Insufficient permissions or invalid path" -Level Warning
                Write-SecurityLog "SOLUTION: Verify OutputPath is writable: $OutputPath" -Level Warning
            }
        }

        "github" {
            foreach ($Dep in $Dependencies) {
                Write-CIAnnotation -Message "[$($Dep.Severity)] $($Dep.Message)" -Level Warning -File $Dep.File
            }

            if (@($Dependencies).Count -eq 0) {
                Write-CIAnnotation -Message "No stale dependencies detected" -Level Notice
            }
            else {
                Write-CIAnnotation -Message "Found $(@($Dependencies).Count) stale dependencies that may pose security risks" -Level Error
            }

            # Build step summary markdown table
            $totalCount = @($Dependencies).Count

            if ($totalCount -eq 0) {
                $summaryContent = @"
# SHA Staleness Analysis

**All Clear:** No stale dependencies detected.

**Found:** 0 | **Stale:** 0
"@
            }
            else {
                $tableRows = foreach ($Dep in $Dependencies) {
                    $status = 'Stale'
                    "| $($Dep.Name) | $($Dep.DaysOld) | $MaxAge | $status |"
                }

                $summaryContent = @"
# SHA Staleness Analysis

**Found:** $totalCount | **Stale:** $totalCount

| Dependency | SHA Age (days) | Threshold (days) | Status |
|------------|----------------|-------------------|--------|
$($tableRows -join "`n")
"@
            }

            Write-CIStepSummary -Content $summaryContent
        }

        "azdo" {
            foreach ($Dep in $Dependencies) {
                Write-CIAnnotation -Message "[$($Dep.Severity)] $($Dep.Message)" -Level Warning -File $Dep.File
            }

            if (@($Dependencies).Count -eq 0) {
                Write-CIAnnotation -Message "No stale dependencies detected" -Level Notice
            }
            else {
                Write-CIAnnotation -Message "Found $(@($Dependencies).Count) stale dependencies that may pose security risks" -Level Error
                Set-CITaskResult -Result SucceededWithIssues
            }
        }

        "console" {
            if (@($Dependencies).Count -eq 0) {
                Write-SecurityLog "No stale dependencies detected!" -Level Success
            }
            else {
                Write-SecurityLog "=== STALE DEPENDENCIES DETECTED ===" -Level Warning
                foreach ($Dep in $Dependencies) {
                    Write-SecurityLog "[$($Dep.Severity)] $($Dep.Type): $($Dep.Name)" -Level Warning
                    Write-SecurityLog "  File: $($Dep.File)" -Level Info
                    Write-SecurityLog "  Message: $($Dep.Message)" -Level Info
                    Write-Information "" -InformationAction Continue
                }
                Write-SecurityLog "Total stale dependencies: $(@($Dependencies).Count)" -Level Warning
            }
        }

        "Summary" {
            if (@($Dependencies).Count -eq 0) {
                Write-Output "No stale dependencies detected!"
            }
            else {
                Write-Output "=== SHA Staleness Summary ==="
                Write-Output "Total stale dependencies: $(@($Dependencies).Count)"
                $ByType = @($Dependencies | Group-Object Type)
                foreach ($Group in $ByType) {
                    Write-Output "$($Group.Name): $($Group.Count)"
                }
            }
        }
    }
}

function Compare-ToolVersion {
    <#
    .SYNOPSIS
        Compares two version strings using semantic versioning rules.
    .DESCRIPTION
        Normalizes version strings by removing v-prefix and pre-release metadata,
        then compares using System.Version when possible.
    .OUTPUTS
        Returns $true if Latest is newer than Current, $false otherwise.
    #>
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [string]$Current,

        [Parameter(Mandatory)]
        [string]$Latest
    )

    # Normalize: strip v prefix, remove pre-release/build metadata
    $normCurrent = $Current -replace '^v', '' -replace '[-+].*$', ''
    $normLatest = $Latest -replace '^v', '' -replace '[-+].*$', ''

    $currentVersion = $null
    $latestVersion = $null

    if ([System.Version]::TryParse($normCurrent, [ref]$currentVersion) -and
        [System.Version]::TryParse($normLatest, [ref]$latestVersion)) {
        return $latestVersion -gt $currentVersion
    }

    # Fallback: string comparison (not ideal but better than nothing)
    Write-Verbose "Version parsing failed, falling back to string comparison"
    return $normLatest -ne $normCurrent
}

function Get-ToolStaleness {
    <#
    .SYNOPSIS
        Checks tool versions against their latest GitHub releases.

    .DESCRIPTION
        Reads the tool-checksums.json manifest and queries the GitHub Releases API
        to detect when tracked tools have newer versions available.

    .PARAMETER ManifestPath
        Path to the tool-checksums.json manifest file.

    .PARAMETER GitHubToken
        GitHub API token for authenticated requests (higher rate limits).
    #>
    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$ManifestPath = (Join-Path $PSScriptRoot "tool-checksums.json"),

        [Parameter()]
        [string]$GitHubToken = $env:GITHUB_TOKEN
    )

    if (-not (Test-Path $ManifestPath)) {
        Write-Warning "Tool manifest not found: $ManifestPath"
        return @()
    }

    $manifest = Get-Content $ManifestPath -Raw | ConvertFrom-Json
    $results = @()

    $headers = @{
        'Accept'               = 'application/vnd.github+json'
        'X-GitHub-Api-Version' = '2022-11-28'
    }
    if ($GitHubToken) {
        $headers['Authorization'] = "Bearer $GitHubToken"
    }

    $apiBase = Get-GitHubApiBase

    foreach ($tool in $manifest.tools) {
        $uri = "$apiBase/repos/$($tool.repo)/releases/latest"
        $latestRelease = Invoke-GitHubAPIWithRetry -Uri $uri -Method GET -Headers $headers

        if ($latestRelease) {
            $latestVersion = $latestRelease.tag_name -replace '^v', ''

            $isStale = Compare-ToolVersion -Current $tool.version -Latest $latestVersion

            $results += [PSCustomObject]@{
                Tool           = $tool.name
                Repository     = $tool.repo
                CurrentVersion = $tool.version
                LatestVersion  = $latestVersion
                IsStale        = $isStale
                CurrentSHA256  = $tool.sha256
                Notes          = $tool.notes
                Error          = $null
            }
        }
        else {
            $errorMsg = "Failed to check $($tool.name): API returned no response"
            Write-Warning $errorMsg

            $results += [PSCustomObject]@{
                Tool           = $tool.name
                Repository     = $tool.repo
                CurrentVersion = $tool.version
                LatestVersion  = $null
                IsStale        = $null
                CurrentSHA256  = $tool.sha256
                Notes          = $tool.notes
                Error          = $errorMsg
            }
        }
    }

    return $results
}

#region Main Execution

function Invoke-SHAStalenessCheck {
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory = $false)]
        [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")]
        [string]$OutputFormat = "console",

        [Parameter(Mandatory = $false)]
        [int]$MaxAge = 30,

        [Parameter(Mandatory = $false)]
        [string]$LogPath = "./logs/sha-staleness-monitoring.log",

        [Parameter(Mandatory = $false)]
        [string]$OutputPath = "./logs/sha-staleness-results.json",

        [Parameter(Mandatory = $false)]
        [switch]$FailOnStale,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 50)]
        [int]$GraphQLBatchSize = 20
    )

    # Ensure logging directory exists (relocated from script scope)
    $LogDir = Split-Path -Parent $LogPath
    if (!(Test-Path $LogDir)) {
        New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
    }

    Write-SecurityLog "Starting SHA staleness monitoring..." -Level Info
    Write-SecurityLog "Max age threshold: $MaxAge days" -Level Info
    Write-SecurityLog "GraphQL batch size: $GraphQLBatchSize queries per request" -Level Info
    Write-SecurityLog "Output format: $OutputFormat" -Level Info

    # Reset stale dependencies for this run
    $script:StaleDependencies = [System.Collections.Generic.List[PSCustomObject]]::new()

    # Run staleness check for GitHub Actions
    Test-GitHubActionsForStaleness

    # Run staleness check for tools from tool-checksums.json
    Write-SecurityLog "Checking tool staleness from tool-checksums.json" -Level Info

    $toolResults = @(Get-ToolStaleness)
    if (@($toolResults).Count -gt 0) {
        $staleTools = @($toolResults | Where-Object { $_.IsStale -eq $true })
        if (@($staleTools).Count -gt 0) {
            Write-SecurityLog "Found $(@($staleTools).Count) stale tool(s):" -Level Warning
            foreach ($tool in $staleTools) {
                Write-SecurityLog "  - $($tool.Tool): $($tool.CurrentVersion) -> $($tool.LatestVersion)" -Level Warning

                $script:StaleDependencies.Add([PSCustomObject]@{
                    Type           = "Tool"
                    File           = "scripts/security/tool-checksums.json"
                    Name           = $tool.Tool
                    CurrentVersion = $tool.CurrentVersion
                    LatestVersion  = $tool.LatestVersion
                    DaysOld        = $null
                    Severity       = "Medium"
                    Message        = "Tool has newer version available: $($tool.CurrentVersion) -> $($tool.LatestVersion)"
                })
            }
        }
        else {
            Write-SecurityLog "All tools are up to date" -Level Info
        }

        $errorTools = @($toolResults | Where-Object { $null -ne $_.Error })
        if (@($errorTools).Count -gt 0) {
            Write-SecurityLog "Failed to check $(@($errorTools).Count) tool(s)" -Level Warning
        }
    }

    Write-SecurityOutput -Dependencies $script:StaleDependencies -OutputFormat $OutputFormat -OutputPath $OutputPath

    Write-SecurityLog "SHA staleness monitoring completed" -Level Success
    Write-SecurityLog "Stale dependencies found: $(@($script:StaleDependencies).Count)" -Level Info

    if (@($script:StaleDependencies).Count -gt 0 -and $FailOnStale) {
        throw "Stale dependencies detected ($(@($script:StaleDependencies).Count) found)"
    }

    if (@($script:StaleDependencies).Count -gt 0) {
        Write-SecurityLog "Stale dependencies found but not failing (use -FailOnStale to fail build)" -Level Warning
    }
}

if ($MyInvocation.InvocationName -ne '.') {
    try {
        Invoke-SHAStalenessCheck -OutputFormat $OutputFormat -MaxAge $MaxAge -LogPath $LogPath -OutputPath $OutputPath -FailOnStale:$FailOnStale -GraphQLBatchSize $GraphQLBatchSize
        exit 0
    }
    catch {
        Write-Error -ErrorAction Continue "Test-SHAStaleness failed: $($_.Exception.Message)"
        Write-CIAnnotation -Message $_.Exception.Message -Level Error
        exit 1
    }
}

#endregion Main Execution