Public/Invoke-Fylgyr.ps1

function Invoke-Fylgyr {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]], [string])]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [ValidatePattern('^[a-zA-Z0-9._-]+$')]
        [string]$Owner,

        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidatePattern('^[a-zA-Z0-9._-]+$')]
        [string]$Repo,

        [ValidateSet('Object', 'JSON', 'SARIF', 'Console', 'NDJSON', 'HTML')]
        [string]$OutputFormat = 'Object',

        [switch]$IncludeOrgChecks,

        [ValidateRange(1, 20)]
        [int]$ThrottleLimit = 5,

        [string[]]$ReusableWorkflowAllowlist = @(),

        [switch]$ChangedOnly,

        [ValidatePattern('^(?!-)[a-zA-Z0-9._/-]+$')]
        [string]$SinceRef = 'origin/main',

        [string]$BaselinePath,

        [switch]$IncludeEvidence,

        [switch]$IgnoreConfig,

        [ValidateSet('Info', 'Low', 'Medium', 'High', 'Critical')]
        [string]$FailOn,

        [string]$OutputPath,

        [string]$Token = $env:GITHUB_TOKEN
    )

    begin {
        if (-not $Token) {
            throw 'GitHub token not provided. Use -Token or set $env:GITHUB_TOKEN.'
        }

        $allResults = [System.Collections.Generic.List[PSCustomObject]]::new()
        $scannedTargets = [System.Collections.Generic.List[string]]::new()
        $scanId = [Guid]::NewGuid().ToString()
        $scanStartTime = [datetime]::UtcNow
        $changedWorkflowPaths = $null

        # Owner-level check caches. Reset every run so repeated Invoke-Fylgyr calls
        # inside the same session do not reuse stale data.
        # - FylgyrOwnerRunnerGroupsChecked: Test-RunnerHygiene consults this to skip the
        # `orgs/{Owner}/...` block on second and later repos in an org-wide scan.
        # - FylgyrOwnerContextCache: owner type/token-owner/plan cache used by
        # Get-FylgyrOwnerContext to avoid repeated users/{owner} and user calls.
        $script:FylgyrOwnerRunnerGroupsChecked = @{}
        $script:FylgyrOwnerContextCache = @{}
    }

    process {
        # If no Repo specified, enumerate all repos for the Owner (org-wide scan)
        if (-not $Repo) {
            if ($ChangedOnly) {
                $allResults.Add((Format-FylgyrResult `
                    -CheckName 'ChangedOnly' `
                    -Status 'Error' `
                    -Severity 'Low' `
                    -Resource $Owner `
                    -Detail 'ChangedOnly mode requires -Repo. Org-wide scans are not supported in ChangedOnly mode.' `
                    -Remediation 'Provide -Repo for ChangedOnly scans, or run without ChangedOnly for org-wide coverage.' `
                    -Target $Owner))
                return
            }

            $repos = [System.Collections.Generic.List[string]]::new()

            try {
                $orgRepos = Invoke-GitHubApi -Endpoint "orgs/$Owner/repos?per_page=100" -Token $Token -AllPages
            }
            catch {
                try {
                    $orgRepos = Invoke-GitHubApi -Endpoint "users/$Owner/repos?per_page=100" -Token $Token -AllPages
                }
                catch {
                    $allResults.Add((Format-FylgyrResult `
                        -CheckName 'OrgRepoList' `
                        -Status 'Error' `
                        -Severity 'Critical' `
                        -Resource $Owner `
                        -Detail "Failed to list repositories for '$Owner': $($_.Exception.Message)" `
                        -Remediation 'Verify the owner exists and the token has repo access.' `
                        -Target $Owner))
                    return
                }
            }

            foreach ($r in $orgRepos) {
                $repos.Add($r.name)
            }

            if ($IncludeOrgChecks) {
                $orgResults = Invoke-FylgyrOrgScan -Owner $Owner -Token $Token
                foreach ($result in $orgResults) { $allResults.Add($result) }
            }

            if ($repos.Count -eq 0) {
                $allResults.Add((Format-FylgyrResult `
                    -CheckName 'OrgRepoList' `
                    -Status 'Warning' `
                    -Severity 'Info' `
                    -Resource $Owner `
                    -Detail "No repositories found for '$Owner'." `
                    -Remediation 'Verify the owner name and token permissions.' `
                    -Target $Owner))
                return
            }

            $repoTotal = $repos.Count
            $effectiveThrottle = Get-FylgyrOrgScanThrottle -RequestedThrottle $ThrottleLimit -RepoTotal $repoTotal -Token $Token

            $isPesterRun = $null -ne (Get-Variable -Name PesterPreference -Scope Global -ErrorAction SilentlyContinue)
            $useParallel = ($effectiveThrottle -gt 1) -and ($repoTotal -gt 1) -and (-not $isPesterRun)

            if ($useParallel) {
                $moduleRoot = Split-Path -Path $PSScriptRoot -Parent
                $modulePath = Join-Path -Path $moduleRoot -ChildPath 'Fylgyr.psm1'
                $scanOwner = $Owner
                $scanToken = $Token
                $scanAllowlist = $ReusableWorkflowAllowlist
                $scanIgnoreConfig = $IgnoreConfig.IsPresent
                $scanIncludeEvidence = $IncludeEvidence.IsPresent
                $repoInputs = for ($i = 0; $i -lt $repoTotal; $i++) {
                    [PSCustomObject]@{
                        Index = $i
                        Repo = $repos[$i]
                    }
                }

                $parallelBatches = $repoInputs | ForEach-Object -Parallel {
                    $repoIndex = [int]$_.Index
                    $repoName = [string]$_.Repo
                    Import-Module -Name $using:modulePath -Force

                    try {
                        $scanResults = @(
                            Invoke-FylgyrScan -Owner $using:scanOwner -Repo $repoName -Token $using:scanToken -ReusableWorkflowAllowlist $using:scanAllowlist -ChangedOnly:$false -ChangedWorkflowPaths @() -IgnoreConfig:$using:scanIgnoreConfig -IncludeEvidence:$using:scanIncludeEvidence
                        )
                    }
                    catch {
                        $target = "$($using:scanOwner)/$repoName"
                        $scanResults = @(
                            (Format-FylgyrResult `
                                -CheckName 'OrgParallelScan' `
                                -Status 'Error' `
                                -Severity 'Critical' `
                                -Resource $target `
                                -Detail "Parallel scan failed: $($_.Exception.Message)" `
                                -Remediation 'Retry with -ThrottleLimit 1 and verify token/repository access.' `
                                -Target $target)
                        )
                    }

                    [PSCustomObject]@{
                        Index = $repoIndex
                        Repo = $repoName
                        Results = $scanResults
                    }
                } -ThrottleLimit $effectiveThrottle

                foreach ($batch in @($parallelBatches | Sort-Object -Property Index)) {
                    foreach ($result in @($batch.Results)) {
                        $allResults.Add($result)
                    }
                    $scannedTargets.Add("$Owner/$($batch.Repo)")
                }
            }
            else {
                for ($i = 0; $i -lt $repoTotal; $i++) {
                    $repoName = $repos[$i]
                    $pct = [math]::Floor(($i / $repoTotal) * 100)
                    Write-Progress -Activity "Scanning $Owner" `
                        -Status "Repo $($i + 1) of $repoTotal : $repoName" `
                        -PercentComplete $pct `
                        -Id 1

                    $repoResults = Invoke-FylgyrScan -Owner $Owner -Repo $repoName -Token $Token -ReusableWorkflowAllowlist $ReusableWorkflowAllowlist -ChangedOnly:$ChangedOnly -ChangedWorkflowPaths $changedWorkflowPaths -IgnoreConfig:$IgnoreConfig -IncludeEvidence:$IncludeEvidence
                    foreach ($result in $repoResults) { $allResults.Add($result) }
                    $scannedTargets.Add("$Owner/$repoName")
                }

                Write-Progress -Activity "Scanning $Owner" -Id 1 -Completed
            }
        }
        else {
            if ($ChangedOnly) {
                try {
                    $changedWorkflowPaths = Get-FylgyrChangedWorkflowPath -SinceRef $SinceRef
                }
                catch {
                    $allResults.Add((Format-FylgyrResult `
                        -CheckName 'ChangedOnly' `
                        -Status 'Error' `
                        -Severity 'Low' `
                        -Resource "$Owner/$Repo" `
                        -Detail "Failed to collect changed files from '$SinceRef': $($_.Exception.Message)" `
                        -Remediation 'Verify SinceRef exists (for example origin/main) and rerun.' `
                        -Target "$Owner/$Repo"))
                }
            }

            $repoResults = Invoke-FylgyrScan -Owner $Owner -Repo $Repo -Token $Token -ReusableWorkflowAllowlist $ReusableWorkflowAllowlist -ChangedOnly:$ChangedOnly -ChangedWorkflowPaths $changedWorkflowPaths -IgnoreConfig:$IgnoreConfig -IncludeEvidence:$IncludeEvidence
            foreach ($result in $repoResults) { $allResults.Add($result) }
            $scannedTargets.Add("$Owner/$Repo")
        }
    }

    end {
        if ($allResults.Count -eq 0) {
            return
        }

        $resultsArray = $allResults.ToArray()

        # Derive display target from scanned targets
        $displayTarget = if ($scannedTargets.Count -eq 1) {
            $scannedTargets[0]
        }
        elseif ($scannedTargets.Count -gt 1) {
            $owners = @($scannedTargets | ForEach-Object { ($_ -split '/')[0] } | Sort-Object -Unique)
            if ($owners.Count -eq 1) { $owners[0] } else { "$($scannedTargets.Count) repositories" }
        }
        else {
            'unknown'
        }

        if ($BaselinePath) {
            try {
                $baselineFingerprints = Get-FylgyrBaselineFingerprintSet -BaselinePath $BaselinePath
                foreach ($result in $resultsArray) {
                    # Baselines are intended for actionable findings only.
                    # Keep scan errors and informational telemetry visible.
                    if ($result.Status -notin @('Fail', 'Warning')) {
                        continue
                    }

                    $fingerprint = Get-FylgyrFingerprint -Result $result
                    if ($baselineFingerprints.Contains($fingerprint)) {
                        $result.Status = 'Suppressed'
                    }
                }
            }
            catch {
                $allResults.Add((Format-FylgyrResult `
                    -CheckName 'BaselineDiff' `
                    -Status 'Error' `
                    -Severity 'Medium' `
                    -Resource $displayTarget `
                    -Detail "Failed to apply baseline diff from '$BaselinePath': $($_.Exception.Message)" `
                    -Remediation 'Provide a valid JSON baseline path (Invoke-Fylgyr JSON output or array of result objects).' `
                    -Target $displayTarget))
                $resultsArray = $allResults.ToArray()
            }
        }

        if ($FailOn) {
            $severityOrder = @{
                Info = 0
                Low = 1
                Medium = 2
                High = 3
                Critical = 4
            }

            $threshold = $severityOrder[$FailOn]
            $hasBlockingFindings = @($resultsArray | Where-Object {
                $_.Status -notin @('Pass', 'Suppressed') -and $severityOrder[$_.Severity] -ge $threshold
            }).Count -gt 0

            $global:LASTEXITCODE = if ($hasBlockingFindings) { 1 } else { 0 }
        }

        if ($OutputFormat -eq 'JSON') {
            ConvertTo-FylgyrJson -Results $resultsArray -Target $displayTarget
        }
        elseif ($OutputFormat -eq 'SARIF') {
            ConvertTo-FylgyrSarif -Results $resultsArray
        }
        elseif ($OutputFormat -eq 'NDJSON') {
            ConvertTo-FylgyrNdjson -Results $resultsArray -ScanId $scanId -ScanStartTime $scanStartTime -OutputPath $OutputPath
        }
        elseif ($OutputFormat -eq 'HTML') {
            ConvertTo-FylgyrHtml -Results $resultsArray -Target $displayTarget -ScannedTargets $scannedTargets.ToArray() -OutputPath $OutputPath
        }
        elseif ($OutputFormat -eq 'Console') {
            Write-FylgyrConsole -Results $resultsArray -Target $displayTarget -ScannedRepoCount $scannedTargets.Count
        }
        else {
            $resultsArray
        }
    }
}

function Invoke-FylgyrScan {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory)]
        [ValidatePattern('^[a-zA-Z0-9._-]+$')]
        [string]$Owner,

        [Parameter(Mandatory)]
        [ValidatePattern('^[a-zA-Z0-9._-]+$')]
        [string]$Repo,

        [Parameter(Mandatory)]
        [string]$Token,

        [string[]]$ReusableWorkflowAllowlist = @(),

        [switch]$ChangedOnly,

        [string[]]$ChangedWorkflowPaths = @(),

        [switch]$IgnoreConfig,

        [switch]$IncludeEvidence
    )

    $target = "$Owner/$Repo"
    $results = [System.Collections.Generic.List[PSCustomObject]]::new()
    $configContext = Get-FylgyrConfigSuppression -IgnoreConfig:$IgnoreConfig
    $configSuppressions = @($configContext.Rules)
    $configDiagnostics = @($configContext.Diagnostics)

    foreach ($configDiagnostic in $configDiagnostics) {
        $results.Add((Format-FylgyrResult `
            -CheckName 'ConfigSuppression' `
            -Status $configDiagnostic.Status `
            -Severity $configDiagnostic.Severity `
            -Resource $target `
            -Detail $configDiagnostic.Detail `
            -Remediation $configDiagnostic.Remediation `
            -Target $target))
    }

    Write-Progress -Activity $target -Status 'Fetching workflow files...' -Id 2 -ParentId 1

    $workflowFiles = $null
    $fetchFailed = $false
    try {
        $workflowFiles = @(Get-WorkflowFile -Owner $Owner -Repo $Repo -Token $Token)
    }
    catch {
        $fetchFailed = $true
        $results.Add((Format-FylgyrResult `
            -CheckName 'WorkflowFileFetch' `
            -Status 'Error' `
            -Severity 'Critical' `
            -Resource $target `
            -Detail "Failed to fetch workflow files: $($_.Exception.Message)" `
            -Remediation 'Verify the repository exists and the token has contents:read access.' `
            -Target $target))
    }

    if ($fetchFailed) {
        # Error already recorded above
    }
    elseif ($ChangedOnly) {
        if (-not $ChangedWorkflowPaths -or $ChangedWorkflowPaths.Count -eq 0) {
            $results.Add((Format-FylgyrResult `
                -CheckName 'ChangedOnly' `
                -Status 'Info' `
                -Severity 'Info' `
                -Resource $target `
                -Detail 'ChangedOnly mode found no changed workflow files under .github/workflows.' `
                -Remediation 'No action needed.' `
                -Target $target))
            $resultArray = $results.ToArray()
            if ($IncludeEvidence) {
                $resultArray = Add-FylgyrEvidence -Results $resultArray -WorkflowFiles @() -Owner $Owner -Repo $Repo -Token $Token
            }
            return (Resolve-FylgyrSuppressionStatus -Results $resultArray -Suppressions $configSuppressions)
        }

        $workflowFiles = @($workflowFiles | Where-Object { $ChangedWorkflowPaths -contains $_.Path })
        if ($workflowFiles.Count -eq 0) {
            $results.Add((Format-FylgyrResult `
                -CheckName 'ChangedOnly' `
                -Status 'Info' `
                -Severity 'Info' `
                -Resource $target `
                -Detail 'ChangedOnly mode detected workflow changes, but none are present in the current repository scan context.' `
                -Remediation 'Ensure changed workflow paths exist in the target repository and rerun.' `
                -Target $target))
            $resultArray = $results.ToArray()
            if ($IncludeEvidence) {
                $resultArray = Add-FylgyrEvidence -Results $resultArray -WorkflowFiles @() -Owner $Owner -Repo $Repo -Token $Token
            }
            return (Resolve-FylgyrSuppressionStatus -Results $resultArray -Suppressions $configSuppressions)
        }
    }
    elseif ($workflowFiles.Count -eq 0) {
        $results.Add((Format-FylgyrResult `
            -CheckName 'WorkflowFileFetch' `
            -Status 'Warning' `
            -Severity 'Info' `
            -Resource $target `
            -Detail 'No workflow files found in .github/workflows.' `
            -Remediation 'No action needed if this repository does not use GitHub Actions.' `
            -Target $target))
    }
    else {
        $workflowChecks = @(
            @{ Name = 'Test-ActionPinning';      Params = @{ WorkflowFiles = $workflowFiles } }
            @{ Name = 'Test-DangerousTrigger';   Params = @{ WorkflowFiles = $workflowFiles; Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-ScriptInjection';    Params = @{ WorkflowFiles = $workflowFiles } }
            @{ Name = 'Test-ArtifactPoisoning';  Params = @{ WorkflowFiles = $workflowFiles } }
            @{ Name = 'Test-OidcTrust';          Params = @{ WorkflowFiles = $workflowFiles } }
            @{ Name = 'Test-CacheIntegrity';     Params = @{ WorkflowFiles = $workflowFiles } }
            @{ Name = 'Test-TriggerFilter';      Params = @{ WorkflowFiles = $workflowFiles } }
            @{ Name = 'Test-DependencyReview';   Params = @{ WorkflowFiles = $workflowFiles } }
            @{ Name = 'Test-ArtifactAttestation'; Params = @{ WorkflowFiles = $workflowFiles } }
            @{ Name = 'Test-ReusableWorkflowTrust'; Params = @{ WorkflowFiles = $workflowFiles; Owner = $Owner; ReusableWorkflowAllowlist = $ReusableWorkflowAllowlist } }
            @{ Name = 'Test-WorkflowPermission'; Params = @{ WorkflowFiles = $workflowFiles } }
            @{ Name = 'Test-RunnerHygiene';      Params = @{ WorkflowFiles = $workflowFiles; Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-EgressControl';      Params = @{ WorkflowFiles = $workflowFiles } }
            @{ Name = 'Test-PublishIntegrity';   Params = @{ WorkflowFiles = $workflowFiles } }
            @{ Name = 'Test-ForkPullPolicy';     Params = @{ WorkflowFiles = $workflowFiles } }
        )

        for ($c = 0; $c -lt $workflowChecks.Count; $c++) {
            $check = $workflowChecks[$c]
            $checkPct = [math]::Floor(($c / $workflowChecks.Count) * 100)
            Write-Progress -Activity $target `
                -Status "Running $($check.Name) ($($workflowFiles.Count) workflow files)" `
                -PercentComplete $checkPct `
                -Id 2 -ParentId 1

            try {
                $checkParams = $check.Params
                $checkResults = & $check.Name @checkParams
                foreach ($r in $checkResults) {
                    $r.Target = $target
                    $results.Add($r)
                }
            }
            catch {
                $results.Add((Format-FylgyrResult `
                    -CheckName $check.Name `
                    -Status 'Error' `
                    -Severity 'Critical' `
                    -Resource $target `
                    -Detail "Check failed with error: $($_.Exception.Message)" `
                    -Remediation 'Review the error and re-run.' `
                    -Target $target))
            }
        }
    }

    # Fork secret exposure check (needs workflow files + API params)
    if (-not $fetchFailed -and $workflowFiles -and $workflowFiles.Count -gt 0) {
        Write-Progress -Activity $target -Status 'Running Test-ForkSecretExposure' -Id 2 -ParentId 1
        try {
            $checkResults = Test-ForkSecretExposure -WorkflowFiles $workflowFiles -Owner $Owner -Repo $Repo -Token $Token
            foreach ($r in $checkResults) {
                $r.Target = $target
                $results.Add($r)
            }
        }
        catch {
            $results.Add((Format-FylgyrResult `
                -CheckName 'Test-ForkSecretExposure' `
                -Status 'Error' `
                -Severity 'Critical' `
                -Resource $target `
                -Detail "Check failed with error: $($_.Exception.Message)" `
                -Remediation 'Review the error and re-run.' `
                -Target $target))
        }
    }

    if (-not $ChangedOnly) {
        # Repo-level checks (always run, regardless of workflow files)
        $repoChecks = @(
            @{ Name = 'Test-BranchProtection';     Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-SecretScanning';       Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-DependabotAlert';      Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-CodeScanning';         Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-CodeOwner';            Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-SignedCommit';         Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-EnvironmentProtection'; Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-RepoVisibility';       Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-WebhookSecurity';      Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-Rulesets';             Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-BinaryArtifact';       Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } }
            @{ Name = 'Test-PrivateVulnReporting'; Params = @{ Owner = $Owner; Repo = $Repo; Token = $Token } }
        )

        foreach ($entry in $repoChecks) {
            Write-Progress -Activity $target -Status "Running $($entry.Name)" -Id 2 -ParentId 1

            try {
                $checkParams = $entry.Params
                $checkResults = & $entry.Name @checkParams
                foreach ($r in $checkResults) {
                    $r.Target = $target
                    $results.Add($r)
                }
            }
            catch {
                $results.Add((Format-FylgyrResult `
                    -CheckName $entry.Name `
                    -Status 'Error' `
                    -Severity 'Critical' `
                    -Resource $target `
                    -Detail "Check failed with error: $($_.Exception.Message)" `
                    -Remediation 'Review the error and re-run.' `
                    -Target $target))
            }
        }
    }

    Write-Progress -Activity $target -Id 2 -Completed

    $resultArray = $results.ToArray()
    if ($IncludeEvidence) {
        $resultArray = Add-FylgyrEvidence -Results $resultArray -WorkflowFiles $workflowFiles -Owner $Owner -Repo $Repo -Token $Token
    }

    Resolve-FylgyrSuppressionStatus -Results $resultArray -Suppressions $configSuppressions
}

function Invoke-FylgyrOrgScan {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory)]
        [ValidatePattern('^[a-zA-Z0-9._-]+$')]
        [string]$Owner,

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

    $target = "org/$Owner"
    $results = [System.Collections.Generic.List[PSCustomObject]]::new()

    $orgChecks = @(
        @{ Name = 'Test-OrgMfaPolicy';          Params = @{ Owner = $Owner; Token = $Token } }
        @{ Name = 'Test-OrgDefaultPermissions'; Params = @{ Owner = $Owner; Token = $Token } }
        @{ Name = 'Test-IpAllowlist';           Params = @{ Owner = $Owner; Token = $Token } }
        @{ Name = 'Test-AuditLogStreaming';     Params = @{ Owner = $Owner; Token = $Token } }
        @{ Name = 'Test-OAuthAppPolicy';        Params = @{ Owner = $Owner; Token = $Token } }
        @{ Name = 'Test-OrgActionRestrictions'; Params = @{ Owner = $Owner; Token = $Token } }
        @{ Name = 'Test-OutsideCollaborators';  Params = @{ Owner = $Owner; Token = $Token } }
        @{ Name = 'Test-PatPolicy';             Params = @{ Owner = $Owner; Token = $Token } }
        @{ Name = 'Test-GitHubAppSecurity';     Params = @{ Owner = $Owner; Token = $Token } }
        @{ Name = 'Test-Rulesets';              Params = @{ Owner = $Owner; Token = $Token } }
    )

    foreach ($entry in $orgChecks) {
        Write-Progress -Activity $target -Status "Running $($entry.Name)" -Id 3 -ParentId 1

        try {
            $checkParams = $entry.Params
            $checkResults = & $entry.Name @checkParams
            foreach ($r in $checkResults) {
                $r.Target = $target
                $results.Add($r)
            }
        }
        catch {
            $normalizedCheckName = $entry.Name -replace '^Test-', ''
            $results.Add((Format-FylgyrResult `
                -CheckName $normalizedCheckName `
                -Status 'Error' `
                -Severity 'Critical' `
                -Resource $target `
                -Detail "Check failed with error: $($_.Exception.Message)" `
                -Remediation 'Review the error and re-run.' `
                -Target $target))
        }
    }

    Write-Progress -Activity $target -Id 3 -Completed

    $results.ToArray()
}