modules/iac/IaCAdapters.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Shared adapter loader for IaC validation tools.
.DESCRIPTION
    Exports Invoke-IaCAdapter which dispatches to flavour-specific validation
    helpers (bicep, terraform). Each adapter returns a v1 wrapper envelope
    (SchemaVersion 1.0, Status, Findings[]) consistent with other wrappers.

    All external process launches go through Invoke-WithTimeout (300s hard cap).
    Invoke-WithTimeout is required; the adapter fails closed if it is unavailable.
    All written output passes through Remove-Credentials.
    All clones go through Invoke-RemoteRepoClone (cloud-first invariant).
#>

[CmdletBinding()]
param ()

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Dot-source shared modules
$sharedDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'shared'
if (-not (Test-Path $sharedDir)) {
    $sharedDir = Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) 'modules' 'shared'
}
$sanitizePath = Join-Path $sharedDir 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
$retryPath = Join-Path $sharedDir 'Retry.ps1'
if (Test-Path $retryPath) { . $retryPath }
$remoteClonePath = Join-Path $sharedDir 'RemoteClone.ps1'
if (Test-Path $remoteClonePath) { . $remoteClonePath }
# Installer.ps1 provides Invoke-WithTimeout (300s hard cap on external processes)
$installerPath = Join-Path $sharedDir 'Installer.ps1'
if (Test-Path $installerPath) { . $installerPath }

if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param ([string]$Text) return $Text }
}

$script:IaCTimeoutSec = 300

# Fail closed if the timeout helper is unavailable
function Assert-TimeoutHelperLoaded {
    if (-not (Get-Command Invoke-WithTimeout -ErrorAction SilentlyContinue)) {
        throw "Required safety primitive Invoke-WithTimeout is not loaded. Ensure Installer.ps1 is available."
    }
}

function Invoke-IaCAdapter {
    <#
    .SYNOPSIS
        Dispatch to a flavour-specific IaC validation adapter.
    .PARAMETER Flavour
        IaC flavour: bicep or terraform.
    .PARAMETER RepoPath
        Local path to the repository root containing IaC files.
    .PARAMETER RemoteUrl
        Remote repository URL; cloned via RemoteClone.ps1 if provided.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('bicep', 'terraform')]
        [string] $Flavour,

        [string] $RepoPath,

        [string] $RemoteUrl,

        [string] $SourceRepoUrl = ''
    )

    $cloneInfo = $null
    $cleanupClone = $null
    try {
        if ($RemoteUrl) {
            if (-not (Get-Command Invoke-RemoteRepoClone -ErrorAction SilentlyContinue)) {
                Write-Warning "RemoteClone helper not loaded; cannot scan remote URL."
                return [PSCustomObject]@{
                    Source = "iac-$Flavour"; Status = 'Failed'
                    SchemaVersion = '1.0'
                    Message = 'RemoteClone helper unavailable'; Findings = @()
                }
            }
            $cloneInfo = Invoke-RemoteRepoClone -RepoUrl $RemoteUrl
            if (-not $cloneInfo) {
                return [PSCustomObject]@{
                    Source = "iac-$Flavour"; Status = 'Failed'
                    SchemaVersion = '1.0'
                    Message = "Remote clone failed or host not on allow-list: $RemoteUrl"
                    Findings = @()
                }
            }
            $cleanupClone = $cloneInfo.Cleanup
            $RepoPath = $cloneInfo.Path
        }

        if (-not $RepoPath) {
            return [PSCustomObject]@{
                Source = "iac-$Flavour"; Status = 'Skipped'
                SchemaVersion = '1.0'
                Message = 'No -RepoPath or -RemoteUrl provided'; Findings = @()
            }
        }

        switch ($Flavour) {
            'bicep' { return Invoke-BicepValidation -RepoPath $RepoPath }
            'terraform' {
                $sourceUrl = if ($RemoteUrl) { $RemoteUrl } else { $SourceRepoUrl }
                return Invoke-TerraformValidation -RepoPath $RepoPath -SourceRepoUrl $sourceUrl
            }
            default {
                return [PSCustomObject]@{
                    Source = "iac-$Flavour"; Status = 'Skipped'
                    SchemaVersion = '1.0'
                    Message = "Unsupported IaC flavour: $Flavour"; Findings = @()
                }
            }
        }
    } catch {
        Write-Warning (Remove-Credentials "IaC adapter ($Flavour) failed: $_")
        return [PSCustomObject]@{
            Source = "iac-$Flavour"; Status = 'Failed'
            SchemaVersion = '1.0'
            Message = Remove-Credentials "$_"; Findings = @()
        }
    } finally {
        if ($cleanupClone) {
            try { & $cleanupClone } catch {
                Write-Verbose "IaC adapter clone cleanup failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))"
            }
        }
    }
}

function Invoke-BicepValidation {
    <#
    .SYNOPSIS
        Run bicep build validation against all .bicep files in a repo.
    .DESCRIPTION
        Each file is compiled via Invoke-WithTimeout (300s hard cap).
        Generated ARM JSON artefacts are cleaned up in a finally block
        so the user's repo is never polluted.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string] $RepoPath
    )

    function Get-BicepDiagnosticMetadata {
        param (
            [string] $LineText,
            [string] $FallbackPath
        )

        $metadata = [ordered]@{
            RuleId      = ''
            Level       = ''
            RelativePath = $FallbackPath
            LineNumber  = ''
            Message     = $LineText
        }

        $pattern = '^(?<path>.+?)\((?<line>\d+)(?:,\d+)?\)\s*:\s*(?<level>Error|Warning|Info)\s+(?<code>[A-Z]{2,}\d+)\s*:\s*(?<message>.+)$'
        $match = [regex]::Match($LineText.Trim(), $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
        if ($match.Success) {
            $rawPath = [string]$match.Groups['path'].Value
            $metadata.RuleId = [string]$match.Groups['code'].Value.ToUpperInvariant()
            $metadata.Level = [string]$match.Groups['level'].Value
            $metadata.LineNumber = [string]$match.Groups['line'].Value
            $metadata.Message = [string]$match.Groups['message'].Value
            if (-not [string]::IsNullOrWhiteSpace($rawPath)) {
                try {
                    $resolved = $rawPath
                    if ([System.IO.Path]::IsPathRooted($rawPath)) {
                        $resolved = $rawPath.Substring($RepoPath.Length).TrimStart('\', '/')
                    }
                    $metadata.RelativePath = $resolved
                } catch {
                    $metadata.RelativePath = $FallbackPath
                }
            }
        }

        if ([string]::IsNullOrWhiteSpace([string]$metadata.RuleId)) {
            $codeMatch = [regex]::Match($LineText, '\b(BCP\d{3}|AZR-[A-Z0-9-]+)\b', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
            if ($codeMatch.Success) { $metadata.RuleId = [string]$codeMatch.Groups[1].Value.ToUpperInvariant() }
        }

        if ([string]::IsNullOrWhiteSpace([string]$metadata.Level)) {
            if ($LineText -match '(?i)\berror\b') { $metadata.Level = 'Error' }
            elseif ($LineText -match '(?i)\bwarning\b') { $metadata.Level = 'Warning' }
            else { $metadata.Level = 'Info' }
        }

        return [PSCustomObject]$metadata
    }

    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()
    $bicepFiles = @(Get-ChildItem -Path $RepoPath -Filter '*.bicep' -Recurse -File -ErrorAction SilentlyContinue)

    if ($bicepFiles.Count -eq 0) {
        return [PSCustomObject]@{
            Source = 'bicep-iac'
            SchemaVersion = '1.0'; Status = 'Success'
            Message = 'No .bicep files found'; Findings = @()
        }
    }

    $generatedJsonFiles = [System.Collections.Generic.List[string]]::new()
    try {
        foreach ($file in $bicepFiles) {
            $relativePath = $file.FullName.Substring($RepoPath.Length).TrimStart('\', '/')
            # Track the ARM JSON that bicep build will generate
            $jsonPath = [System.IO.Path]::ChangeExtension($file.FullName, '.json')
            $generatedJsonFiles.Add($jsonPath)

            try {
                Assert-TimeoutHelperLoaded
                $result = Invoke-WithTimeout -Command 'bicep' -Arguments @('build', $file.FullName) -TimeoutSec $script:IaCTimeoutSec
                $exitCode = $result.ExitCode
                $outputText = $result.Output

                if ($exitCode -ne 0) {
                    $errorLines = @($outputText -split "`n" | Where-Object { $_ -match '(Error|Warning)\s' })

                    foreach ($line in $errorLines) {
                        $lineStr = Remove-Credentials $line
                        $diag = Get-BicepDiagnosticMetadata -LineText $lineStr -FallbackPath $relativePath
                        $severity = if ($diag.Level -match '^(?i)error$') { 'Error' }
                                    elseif ($diag.Level -match '^(?i)warning$') { 'Warning' }
                                    else { 'Info' }
                        $category = 'IaC Validation'
                        if ($lineStr -match '(?i)security|secret|password|keyvault|identity|rbac|tls|encrypt') { $category = 'Security' }
                        elseif ($lineStr -match '(?i)cost|sku|pricing|size') { $category = 'Cost' }
                        elseif ($lineStr -match '(?i)availability|zone|region|failover|backup') { $category = 'Reliability' }
                        elseif ($lineStr -match '(?i)performance|throughput|latency|concurrency') { $category = 'Performance' }
                        elseif ($lineStr -match '(?i)diagnostic|logging|monitor|governance|policy') { $category = 'Operations' }

                        $findings.Add([PSCustomObject]@{
                            Id          = [guid]::NewGuid().ToString()
                            RuleId      = $diag.RuleId
                            Level       = $diag.Level
                            Category    = $category
                            Title       = "Bicep diagnostic $($diag.RuleId): $($diag.RelativePath)"
                            Severity    = $severity
                            Compliant   = $false
                            Detail      = $lineStr.Trim()
                            Remediation = "Fix rule $($diag.RuleId) in $($diag.RelativePath)"
                            ResourceId  = $diag.RelativePath
                            LearnMoreUrl = 'https://learn.microsoft.com/azure/azure-resource-manager/bicep/overview'
                            LineNumber  = $diag.LineNumber
                        })
                    }

                    if ($errorLines.Count -eq 0) {
                        $findings.Add([PSCustomObject]@{
                            Id          = [guid]::NewGuid().ToString()
                            RuleId      = 'BICEP-BUILD-FAILED'
                            Level       = 'Error'
                            Category    = 'IaC Validation'
                            Title       = "Bicep build failed: $relativePath"
                            Severity    = 'Error'
                            Compliant   = $false
                            Detail      = Remove-Credentials $outputText
                            Remediation = "Fix the Bicep file at $relativePath"
                            ResourceId  = $relativePath
                            LearnMoreUrl = 'https://learn.microsoft.com/azure/azure-resource-manager/bicep/overview'
                        })
                    }
                }
            } catch {
                # Re-throw safety primitive failures (missing timeout helper)
                if ($_.Exception.Message -match 'Invoke-WithTimeout') { throw }
                $findings.Add([PSCustomObject]@{
                    Id          = [guid]::NewGuid().ToString()
                    RuleId      = 'BICEP-VALIDATION-ERROR'
                    Level       = 'Error'
                    Category    = 'IaC Validation'
                    Title       = "Bicep validation error: $relativePath"
                    Severity    = 'Error'
                    Compliant   = $false
                    Detail      = Remove-Credentials ([string]$_)
                    Remediation = "Ensure bicep CLI is available and the file is valid"
                    ResourceId  = $relativePath
                    LearnMoreUrl = 'https://learn.microsoft.com/azure/azure-resource-manager/bicep/overview'
                })
            }
        }
    } finally {
        # Always clean up generated ARM JSON files so the user's repo is not polluted
        foreach ($jsonPath in $generatedJsonFiles) {
            if (Test-Path $jsonPath) {
                Remove-Item $jsonPath -Force -ErrorAction SilentlyContinue
            }
        }
    }

    return [PSCustomObject]@{
        Source   = 'bicep-iac'
        SchemaVersion = '1.0'
        Status   = 'Success'
        Message  = ''
        Findings = $findings
    }
}

function Get-TerraformToolVersion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string] $Command,
        [string[]] $Arguments = @('--version')
    )

    if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { return '' }
    try {
        Assert-TimeoutHelperLoaded
        $result = Invoke-WithTimeout -Command $Command -Arguments $Arguments -TimeoutSec $script:IaCTimeoutSec
        if ($result.ExitCode -eq 0 -and $result.Output) {
            return (($result.Output -split "`r?`n")[0]).Trim()
        }
    } catch {
        Write-Verbose "Version probe failed for $Command`: $(Remove-Credentials ([string]$_.Exception.Message))"
    }
    return ''
}

function Resolve-TerraformToolLabel {
    param ([string] $RuleId)
    if ([string]::IsNullOrWhiteSpace($RuleId)) { return 'terraform' }
    if ($RuleId -match '^(?i)CKV_') { return 'checkov' }
    if ($RuleId -match '^(?i)TFSEC|^AWS\d+|^AZU\d+') { return 'tfsec' }
    return 'trivy'
}

function Resolve-TerraformRulePillar {
    param (
        [string] $RuleId,
        [string] $Title,
        [string] $Description,
        [string] $Category
    )

    $signal = "$RuleId $Title $Description $Category".ToLowerInvariant()
    if ($signal -match 'cost|price|sku|sizing|idle|rightsiz') { return 'CostOptimization' }
    if ($signal -match 'performance|latency|throughput|iops|autoscal|cache') { return 'PerformanceEfficiency' }
    if ($signal -match 'operations|operational|monitor|logging|diagnostic|tagging|policy') { return 'OperationalExcellence' }
    if ($signal -match 'reliability|availability|redundan|backup|recovery|resilien') { return 'Reliability' }
    return 'Security'
}

function Resolve-TerraformFrameworks {
    param (
        [string] $RuleId,
        [string] $Title,
        [string] $Description
    )

    $frameworks = [System.Collections.Generic.List[hashtable]]::new()
    $signal = "$RuleId $Title $Description".ToLowerInvariant()
    $control = if ([string]::IsNullOrWhiteSpace($RuleId)) { 'terraform-validate' } else { $RuleId.ToUpperInvariant() }

    function Add-Framework {
        param([string] $Kind, [string] $ControlId)
        if ([string]::IsNullOrWhiteSpace($Kind) -or [string]::IsNullOrWhiteSpace($ControlId)) { return }
        foreach ($existing in $frameworks) {
            if ($existing.kind -eq $Kind -and $existing.controlId -eq $ControlId) { return }
        }
        $frameworks.Add(@{ kind = $Kind; controlId = $ControlId }) | Out-Null
    }

    if ($signal -match 'avd-azu-|azure|azurerm|azapi') {
        Add-Framework -Kind 'Azure WAF' -ControlId $control
        Add-Framework -Kind 'CIS Azure' -ControlId $control
        Add-Framework -Kind 'Azure Security Benchmark' -ControlId $control
        Add-Framework -Kind 'NIST 800-53' -ControlId $control
    } else {
        if ($signal -match 'waf|well-architected') { Add-Framework -Kind 'Azure WAF' -ControlId $control }
        if ($signal -match 'cis') { Add-Framework -Kind 'CIS Azure' -ControlId $control }
        if ($signal -match 'asb|azure security benchmark|microsoft cloud security benchmark') { Add-Framework -Kind 'Azure Security Benchmark' -ControlId $control }
        if ($signal -match 'nist') { Add-Framework -Kind 'NIST 800-53' -ControlId $control }
    }

    return @($frameworks)
}

function Resolve-TerraformDeepLinkUrl {
    param (
        [string] $RuleId,
        [string] $PrimaryUrl,
        [string[]] $References
    )

    if (-not [string]::IsNullOrWhiteSpace($PrimaryUrl)) { return $PrimaryUrl.Trim() }
    foreach ($reference in @($References)) {
        if (-not [string]::IsNullOrWhiteSpace([string]$reference)) { return ([string]$reference).Trim() }
    }
    if ([string]::IsNullOrWhiteSpace($RuleId)) { return '' }

    $upperRule = $RuleId.Trim().ToUpperInvariant()
    if ($upperRule -match '^AVD-[A-Z]+-\d+$') {
        return "https://avd.aquasec.com/misconfig/$($upperRule.ToLowerInvariant())"
    }
    if ($upperRule -match '^TFSEC') {
        return "https://aquasecurity.github.io/tfsec/latest/checks/#$($upperRule.ToLowerInvariant())"
    }
    if ($upperRule -match '^CKV_') {
        return "https://www.checkov.io/5.Policy%20Index/all.html#$($upperRule.ToLowerInvariant())"
    }
    return ''
}

function Resolve-TerraformProviderTag {
    param (
        [string] $RuleId,
        [string] $Title,
        [string] $Description
    )

    $signal = "$RuleId $Title $Description".ToLowerInvariant()
    if ($signal -match 'azapi') { return 'azapi' }
    return 'azurerm'
}

function Resolve-TerraformRemediationSnippets {
    param (
        [string] $RuleId,
        [string] $Resolution
    )

    $snippets = [System.Collections.Generic.List[hashtable]]::new()
    $rule = if ($RuleId) { $RuleId.ToUpperInvariant() } else { '' }
    if ($rule -eq 'AVD-AZU-0001') {
        $snippets.Add(@{
                language = 'hcl'
                code     = "- allow_blob_public_access = true`n+ allow_blob_public_access = false"
            }) | Out-Null
    } elseif ($rule -eq 'AVD-AZU-0050') {
        $snippets.Add(@{
                language = 'hcl'
                code     = "- purge_protection_enabled = false`n+ purge_protection_enabled = true"
            }) | Out-Null
    } elseif (-not [string]::IsNullOrWhiteSpace($Resolution)) {
        $snippets.Add(@{
                language = 'hcl'
                code     = "- # insecure configuration`n+ # remediation: $($Resolution.Trim())"
            }) | Out-Null
    }

    return @($snippets)
}

function Resolve-TerraformGitHubBlobBase {
    param ([string] $SourceRepoUrl)
    if ([string]::IsNullOrWhiteSpace($SourceRepoUrl)) { return '' }
    $trimmed = $SourceRepoUrl.Trim()

    if ($trimmed -match '^(?i)https://([^/]+)/([^/]+)/([^/.]+?)(?:\.git)?/?$') {
        return "https://$($Matches[1])/$($Matches[2])/$($Matches[3])/blob/HEAD"
    }
    if ($trimmed -match '^(?i)github\.com/([^/]+)/([^/.]+?)(?:\.git)?/?$') {
        return "https://github.com/$($Matches[1])/$($Matches[2])/blob/HEAD"
    }
    return ''
}

function Resolve-TerraformEvidenceUris {
    param (
        [string] $RepoPath,
        [string] $RelativeDir,
        [string] $TargetPath,
        [int] $Line,
        [string] $SourceRepoUrl
    )

    $target = if ([string]::IsNullOrWhiteSpace($TargetPath)) { 'main.tf' } else { $TargetPath }
    $relativePath = if ([System.IO.Path]::IsPathRooted($target)) {
        if ($target.StartsWith($RepoPath, [System.StringComparison]::OrdinalIgnoreCase)) {
            $target.Substring($RepoPath.Length).TrimStart('\', '/')
        } else {
            [System.IO.Path]::GetFileName($target)
        }
    } elseif ($RelativeDir -eq '.' -or [string]::IsNullOrWhiteSpace($RelativeDir)) {
        $target.TrimStart('\', '/')
    } else {
        Join-Path $RelativeDir $target
    }
    $relativePath = $relativePath -replace '\\', '/'

    $lineAnchor = if ($Line -gt 0) { "#L$Line" } else { '' }
    $uris = [System.Collections.Generic.List[string]]::new()
    $blobBase = Resolve-TerraformGitHubBlobBase -SourceRepoUrl $SourceRepoUrl
    if (-not [string]::IsNullOrWhiteSpace($blobBase)) {
        $uris.Add("$blobBase/$relativePath$lineAnchor") | Out-Null
    }
    $uris.Add("file://$relativePath$lineAnchor") | Out-Null
    return @($uris | Select-Object -Unique)
}

function Resolve-TerraformEntityRefs {
    param (
        [string] $RelativePath,
        [string] $ResourceAddress
    )

    $refs = [System.Collections.Generic.List[string]]::new()
    $path = $RelativePath -replace '\\', '/' -replace '^\./', ''
    if (-not [string]::IsNullOrWhiteSpace($path)) {
        $refs.Add("iac:terraform:$path") | Out-Null
    }
    if (-not [string]::IsNullOrWhiteSpace($ResourceAddress)) {
        $refs.Add("iac:terraform:$path#$ResourceAddress") | Out-Null
        if ($ResourceAddress -match '^(module\.[^.]+)') {
            $refs.Add("iac:terraform:$path#$($Matches[1])") | Out-Null
        }
    }
    return @($refs | Select-Object -Unique)
}

function Invoke-TerraformValidation {
    <#
    .SYNOPSIS
        Run terraform validate and trivy config against Terraform directories.
    .DESCRIPTION
        Discovers directories containing .tf files and runs terraform validate
        (syntax-only, no init required for basic validation) plus trivy config
        for security scanning of HCL files.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string] $RepoPath,
        [string] $SourceRepoUrl = ''
    )

    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()
    $tfFiles = @(Get-ChildItem -Path $RepoPath -Filter '*.tf' -Recurse -File -ErrorAction SilentlyContinue)

    if ($tfFiles.Count -eq 0) {
        return [PSCustomObject]@{
            Source = 'terraform-iac'
            SchemaVersion = '1.0'; Status = 'Success'
            Message = 'No .tf files found'; Findings = @()
        }
    }

    $versions = @{
        terraform = Get-TerraformToolVersion -Command 'terraform' -Arguments @('version')
        trivy     = Get-TerraformToolVersion -Command 'trivy' -Arguments @('--version')
        tfsec     = Get-TerraformToolVersion -Command 'tfsec' -Arguments @('--version')
        checkov   = Get-TerraformToolVersion -Command 'checkov' -Arguments @('--version')
    }

    $summary = [System.Collections.Generic.List[string]]::new()
    foreach ($key in @('terraform', 'trivy', 'tfsec', 'checkov')) {
        if (-not [string]::IsNullOrWhiteSpace([string]$versions[$key])) {
            $summary.Add($versions[$key]) | Out-Null
        }
    }
    $toolVersionSummary = ($summary -join '; ')

    # Get unique directories containing .tf files
    $tfDirs = $tfFiles | ForEach-Object { $_.DirectoryName } | Select-Object -Unique

    foreach ($dir in $tfDirs) {
        $relativeDir = $dir.Substring($RepoPath.Length).TrimStart('\', '/')
        if (-not $relativeDir) { $relativeDir = '.' }

        Invoke-TerraformValidateDir -RepoPath $RepoPath -Dir $dir -RelativeDir $relativeDir -Findings $findings -ToolVersions $versions -ToolVersionSummary $toolVersionSummary -SourceRepoUrl $SourceRepoUrl
        Invoke-TrivyConfigDir -RepoPath $RepoPath -Dir $dir -RelativeDir $relativeDir -Findings $findings -ToolVersions $versions -ToolVersionSummary $toolVersionSummary -SourceRepoUrl $SourceRepoUrl
    }

    return [PSCustomObject]@{
        Source      = 'terraform-iac'
        SchemaVersion = '1.0'
        Status      = 'Success'
        Message     = ''
        ToolVersion = $toolVersionSummary
        Findings    = $findings
    }
}

function Invoke-TerraformValidateDir {
    <#
    .SYNOPSIS
        Run terraform validate against a single directory.
    .DESCRIPTION
        Uses Invoke-WithTimeout (300s) for the external process call.
        Exit code is captured via the timeout helper's return object,
        avoiding script-scope variable races under WorkerPool concurrency.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string] $RepoPath,

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

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

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[PSCustomObject]] $Findings,

        [hashtable] $ToolVersions = @{},
        [string] $ToolVersionSummary = '',
        [string] $SourceRepoUrl = ''
    )

    if (-not (Get-Command terraform -ErrorAction SilentlyContinue)) {
        return
    }

    try {
        Assert-TimeoutHelperLoaded

        # terraform validate requires init on fresh clones; run init -backend=false
        # to download provider schemas without configuring remote state
        $initResult = Invoke-WithTimeout -Command 'terraform' -Arguments @('-chdir', $Dir, 'init', '-backend=false', '-input=false') -TimeoutSec $script:IaCTimeoutSec
        if ($initResult.ExitCode -ne 0) {
            $evidenceUris = Resolve-TerraformEvidenceUris -RepoPath $RepoPath -RelativeDir $RelativeDir -TargetPath 'main.tf' -Line 0 -SourceRepoUrl $SourceRepoUrl
            # init failed; emit a finding and skip validate for this directory
            $Findings.Add([PSCustomObject]@{
                Id          = [guid]::NewGuid().ToString()
                Category    = 'IaC Validation'
                Title       = "Terraform init required: $RelativeDir"
                RuleId      = 'terraform-init'
                Severity    = 'Medium'
                Compliant   = $false
                Detail      = Remove-Credentials "terraform init -backend=false failed. Provider plugins may be unavailable. Output: $($initResult.Output.Substring(0, [Math]::Min($initResult.Output.Length, 500)))"
                Remediation = "Run 'terraform init' in $RelativeDir before validation, or ensure provider plugins are accessible."
                ResourceId  = $RelativeDir
                LearnMoreUrl = 'https://developer.hashicorp.com/terraform/cli/commands/init'
                Pillar      = 'OperationalExcellence'
                Frameworks  = @()
                DeepLinkUrl = 'https://developer.hashicorp.com/terraform/cli/commands/init'
                RemediationSnippets = @(@{
                            language = 'hcl'
                            code     = "- # provider not initialized`n+ terraform init -backend=false"
                        })
                EvidenceUris = $evidenceUris
                BaselineTags = @('terraform:rule:terraform-init','terraform:provider:azurerm','terraform:tool:terraform')
                EntityRefs   = Resolve-TerraformEntityRefs -RelativePath (Join-Path $RelativeDir 'main.tf') -ResourceAddress ''
                ToolVersion  = if ($ToolVersions.terraform) { [string]$ToolVersions.terraform } else { $ToolVersionSummary }
            })
            return
        }

        $result = Invoke-WithTimeout -Command 'terraform' -Arguments @('-chdir', $Dir, 'validate', '-json') -TimeoutSec $script:IaCTimeoutSec
        $exitCode = $result.ExitCode
        $jsonText = $result.Output

        if ($exitCode -ne 0 -and $jsonText) {
            try {
                $parsed = $jsonText | ConvertFrom-Json -ErrorAction Stop
                if ($parsed.PSObject.Properties['diagnostics'] -and $parsed.diagnostics) {
                    foreach ($diag in $parsed.diagnostics) {
                        $severity = switch ($diag.severity) {
                            'error'   { 'High' }
                            'warning' { 'Medium' }
                            default   { 'Medium' }
                        }
                        $detail = if ($diag.PSObject.Properties['detail'] -and $diag.detail) { $diag.detail } else { $diag.summary }
                        $line = 0
                        $targetPath = 'main.tf'
                        if ($diag.PSObject.Properties['range'] -and $diag.range) {
                            if ($diag.range.PSObject.Properties['start'] -and $diag.range.start -and $diag.range.start.PSObject.Properties['line']) {
                                $line = [int]$diag.range.start.line
                            }
                            if ($diag.range.PSObject.Properties['filename'] -and $diag.range.filename) {
                                $targetPath = [string]$diag.range.filename
                            }
                        }
                        $evidenceUris = Resolve-TerraformEvidenceUris -RepoPath $RepoPath -RelativeDir $RelativeDir -TargetPath $targetPath -Line $line -SourceRepoUrl $SourceRepoUrl
                        $relativePath = ($evidenceUris[0] -replace '^file://', '') -replace '#L\d+$', ''
                        $Findings.Add([PSCustomObject]@{
                            Id          = [guid]::NewGuid().ToString()
                            Category    = 'IaC Validation'
                            Title       = "Terraform validate: $($diag.summary)"
                            RuleId      = 'terraform-validate'
                            Severity    = $severity
                            Compliant   = $false
                            Detail      = Remove-Credentials $detail
                            Remediation = "Fix the Terraform configuration in $RelativeDir"
                            ResourceId  = $RelativeDir
                            LearnMoreUrl = 'https://developer.hashicorp.com/terraform/cli/commands/validate'
                            Pillar      = 'OperationalExcellence'
                            Frameworks  = @()
                            DeepLinkUrl = 'https://developer.hashicorp.com/terraform/cli/commands/validate'
                            RemediationSnippets = @(@{
                                        language = 'hcl'
                                        code     = "- # failing expression`n+ # update expression to satisfy terraform validate"
                                    })
                            EvidenceUris = $evidenceUris
                            BaselineTags = @('terraform:rule:terraform-validate','terraform:provider:azurerm','terraform:tool:terraform')
                            EntityRefs   = Resolve-TerraformEntityRefs -RelativePath $relativePath -ResourceAddress ''
                            ToolVersion  = if ($ToolVersions.terraform) { [string]$ToolVersions.terraform } else { $ToolVersionSummary }
                        })
                    }
                }
            } catch {
                $evidenceUris = Resolve-TerraformEvidenceUris -RepoPath $RepoPath -RelativeDir $RelativeDir -TargetPath 'main.tf' -Line 0 -SourceRepoUrl $SourceRepoUrl
                $Findings.Add([PSCustomObject]@{
                    Id          = [guid]::NewGuid().ToString()
                    Category    = 'IaC Validation'
                    Title       = "Terraform validate failed: $RelativeDir"
                    RuleId      = 'terraform-validate'
                    Severity    = 'High'
                    Compliant   = $false
                    Detail      = Remove-Credentials ($jsonText.Substring(0, [Math]::Min($jsonText.Length, 500)))
                    Remediation = "Fix the Terraform configuration in $RelativeDir"
                    ResourceId  = $RelativeDir
                    LearnMoreUrl = 'https://developer.hashicorp.com/terraform/cli/commands/validate'
                    Pillar      = 'OperationalExcellence'
                    Frameworks  = @()
                    DeepLinkUrl = 'https://developer.hashicorp.com/terraform/cli/commands/validate'
                    RemediationSnippets = @(@{
                                language = 'hcl'
                                code     = "- # invalid terraform configuration`n+ # fix diagnostics and re-run terraform validate"
                            })
                    EvidenceUris = $evidenceUris
                    BaselineTags = @('terraform:rule:terraform-validate','terraform:provider:azurerm','terraform:tool:terraform')
                    EntityRefs   = Resolve-TerraformEntityRefs -RelativePath (Join-Path $RelativeDir 'main.tf') -ResourceAddress ''
                    ToolVersion  = if ($ToolVersions.terraform) { [string]$ToolVersions.terraform } else { $ToolVersionSummary }
                })
            }
        }
    } catch {
        # Re-throw safety primitive failures (missing timeout helper)
        if ($_.Exception.Message -match 'Invoke-WithTimeout') { throw }
        Write-Verbose "terraform validate failed in $Dir`: $(Remove-Credentials ([string]$_))"
    }
}

function Invoke-TrivyConfigDir {
    <#
    .SYNOPSIS
        Run trivy config against a directory for HCL/Terraform security findings.
    .DESCRIPTION
        Uses trivy config (which subsumes tfsec) for IaC security scanning.
        External process is wrapped in Invoke-WithTimeout (300s hard cap).
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string] $RepoPath,

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

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

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[PSCustomObject]] $Findings,

        [hashtable] $ToolVersions = @{},
        [string] $ToolVersionSummary = '',
        [string] $SourceRepoUrl = ''
    )

    if (-not (Get-Command trivy -ErrorAction SilentlyContinue)) {
        return
    }

    $reportFile = Join-Path ([System.IO.Path]::GetTempPath()) "trivy-config-$([guid]::NewGuid().ToString('N')).json"
    try {
        Assert-TimeoutHelperLoaded
        $result = Invoke-WithTimeout -Command 'trivy' -Arguments @('config', '--format', 'json', '--output', $reportFile, $Dir) -TimeoutSec $script:IaCTimeoutSec
        if ($result.ExitCode -eq -1) {
            $evidenceUris = Resolve-TerraformEvidenceUris -RepoPath $RepoPath -RelativeDir $RelativeDir -TargetPath 'main.tf' -Line 0 -SourceRepoUrl $SourceRepoUrl
            $Findings.Add([PSCustomObject]@{
                Id          = [guid]::NewGuid().ToString()
                Category    = 'IaC Security'
                Title       = "Trivy scan incomplete: timed out after $($script:IaCTimeoutSec)s"
                RuleId      = 'trivy-timeout'
                Severity    = 'High'
                Compliant   = $false
                Detail      = Remove-Credentials "trivy config timed out after $($script:IaCTimeoutSec) seconds scanning $RelativeDir. Security findings may be missing."
                Remediation = "Reduce the scan scope or increase the timeout budget. Consider scanning subdirectories individually."
                ResourceId  = $RelativeDir
                LearnMoreUrl = 'https://github.com/aquasecurity/trivy'
                Pillar      = 'Security'
                Frameworks  = @()
                DeepLinkUrl = 'https://github.com/aquasecurity/trivy'
                RemediationSnippets = @(@{
                            language = 'hcl'
                            code     = "- # scan scope too broad`n+ # split Terraform modules into smaller directories"
                        })
                EvidenceUris = $evidenceUris
                BaselineTags = @('terraform:rule:trivy-timeout','terraform:provider:azurerm','terraform:tool:trivy')
                EntityRefs   = Resolve-TerraformEntityRefs -RelativePath (Join-Path $RelativeDir 'main.tf') -ResourceAddress ''
                ToolVersion  = if ($ToolVersions.trivy) { [string]$ToolVersions.trivy } else { $ToolVersionSummary }
            })
            return
        }

        if (Test-Path $reportFile) {
            $jsonText = Get-Content $reportFile -Raw -ErrorAction SilentlyContinue
            if ($jsonText) {
                try {
                    $json = $jsonText | ConvertFrom-Json -ErrorAction Stop
                } catch {
                    Write-Verbose "trivy config JSON parse failed: $(Remove-Credentials ([string]$_))"
                    return
                }

                $results = $null
                if ($null -ne $json -and $json.PSObject.Properties['Results'] -and $json.Results) {
                    $results = $json.Results
                }

                if ($results) {
                    foreach ($result in $results) {
                        $misconfigs = $null
                        if ($result.PSObject.Properties['Misconfigurations'] -and $result.Misconfigurations) {
                            $misconfigs = $result.Misconfigurations
                        }
                        if (-not $misconfigs) { continue }

                        foreach ($mc in $misconfigs) {
                            $mcId = if ($mc.PSObject.Properties['ID'] -and $mc.ID) { $mc.ID } else { '' }
                            $mcTitle = if ($mc.PSObject.Properties['Title'] -and $mc.Title) { $mc.Title } else { '' }
                            $mcDesc = if ($mc.PSObject.Properties['Description'] -and $mc.Description) { $mc.Description } else { '' }
                            $mcRes = if ($mc.PSObject.Properties['Resolution'] -and $mc.Resolution) { $mc.Resolution } else { '' }
                            $mcSev = if ($mc.PSObject.Properties['Severity'] -and $mc.Severity) { $mc.Severity } else { 'MEDIUM' }
                            $mcPrimary = if ($mc.PSObject.Properties['PrimaryURL'] -and $mc.PrimaryURL) { [string]$mc.PrimaryURL } else { '' }
                            $mcReferences = if ($mc.PSObject.Properties['References'] -and $mc.References) { @($mc.References) } else { @() }
                            $mcUrl = ''
                            if (-not [string]::IsNullOrWhiteSpace($mcPrimary)) {
                                $mcUrl = $mcPrimary
                            } elseif ($mcReferences.Count -gt 0) {
                                $mcUrl = [string]$mcReferences[0]
                            }

                            $severity = switch -Regex ($mcSev.ToString().ToLowerInvariant()) {
                                'CRITICAL' { 'Critical' }
                                'HIGH'     { 'High' }
                                'MEDIUM'   { 'Medium' }
                                'LOW'      { 'Low' }
                                'UNKNOWN'  { 'Info' }
                                default    { 'Info' }
                            }

                            $title = if ($mcId -and $mcTitle) { "$mcId`: $mcTitle" }
                                     elseif ($mcId) { $mcId }
                                     elseif ($mcTitle) { $mcTitle }
                                     else { 'Unknown misconfiguration' }
                            $targetPath = if ($result.PSObject.Properties['Target'] -and $result.Target) { [string]$result.Target } else { 'main.tf' }
                            $resourceAddress = ''
                            if ($mc.PSObject.Properties['CauseMetadata'] -and $mc.CauseMetadata -and $mc.CauseMetadata.PSObject.Properties['Resource'] -and $mc.CauseMetadata.Resource) {
                                $resourceAddress = [string]$mc.CauseMetadata.Resource
                            } elseif ($mc.PSObject.Properties['Query'] -and $mc.Query) {
                                $resourceAddress = [string]$mc.Query
                            }
                            $startLine = 0
                            if ($mc.PSObject.Properties['CauseMetadata'] -and $mc.CauseMetadata -and $mc.CauseMetadata.PSObject.Properties['StartLine'] -and $mc.CauseMetadata.StartLine) {
                                $startLine = [int]$mc.CauseMetadata.StartLine
                            }
                            $evidenceUris = Resolve-TerraformEvidenceUris -RepoPath $RepoPath -RelativeDir $RelativeDir -TargetPath $targetPath -Line $startLine -SourceRepoUrl $SourceRepoUrl
                            $relativePath = ($evidenceUris[0] -replace '^file://', '') -replace '#L\d+$', ''
                            $providerTag = Resolve-TerraformProviderTag -RuleId $mcId -Title $mcTitle -Description $mcDesc
                            $toolLabel = Resolve-TerraformToolLabel -RuleId $mcId
                            $ruleId = if ($mcId) { [string]$mcId } else { 'terraform-misconfiguration' }
                            $deepLinkUrl = Resolve-TerraformDeepLinkUrl -RuleId $ruleId -PrimaryUrl $mcPrimary -References $mcReferences
                            $frameworks = Resolve-TerraformFrameworks -RuleId $ruleId -Title $mcTitle -Description $mcDesc
                            $pillar = Resolve-TerraformRulePillar -RuleId $ruleId -Title $mcTitle -Description $mcDesc -Category 'IaC Security'
                            $remediationSnippets = Resolve-TerraformRemediationSnippets -RuleId $ruleId -Resolution $mcRes
                            $entityRefs = Resolve-TerraformEntityRefs -RelativePath $relativePath -ResourceAddress $resourceAddress
                            $toolVersion = ''
                            if ($ToolVersions.ContainsKey($toolLabel) -and $ToolVersions[$toolLabel]) {
                                $toolVersion = [string]$ToolVersions[$toolLabel]
                            } elseif ($ToolVersions.trivy) {
                                $toolVersion = [string]$ToolVersions.trivy
                            } else {
                                $toolVersion = $ToolVersionSummary
                            }

                            $Findings.Add([PSCustomObject]@{
                                Id          = [guid]::NewGuid().ToString()
                                Category    = 'IaC Security'
                                Title       = $title
                                RuleId      = $ruleId
                                Severity    = $severity
                                Compliant   = $false
                                Detail      = Remove-Credentials $mcDesc
                                Remediation = $mcRes
                                ResourceId  = $RelativeDir
                                LearnMoreUrl = $mcUrl
                                Pillar      = $pillar
                                Frameworks  = $frameworks
                                DeepLinkUrl = $deepLinkUrl
                                RemediationSnippets = $remediationSnippets
                                EvidenceUris = $evidenceUris
                                BaselineTags = @("terraform:rule:$($ruleId.ToLowerInvariant())","terraform:provider:$providerTag","terraform:tool:$toolLabel")
                                EntityRefs   = $entityRefs
                                ResourceAddress = $resourceAddress
                                ToolVersion = $toolVersion
                            })
                        }
                    }
                }
            }
        }
    } finally {
        Remove-Item $reportFile -Force -ErrorAction SilentlyContinue
    }
}