modules/Invoke-Kubescape.ps1

#requires -Version 7.0
<#
.SYNOPSIS
    Wrapper for kubescape - Kubernetes posture scanning against CIS K8s Benchmark + NSA/CISA hardening.

.DESCRIPTION
    Discovers AKS managed clusters in scope via Azure Resource Graph, fetches kubeconfig
    for each with `az aks get-credentials --overwrite-existing`, and runs
    `kubescape scan --format json --output <tempfile>` against each cluster context.

    Returns a standardized v1 tool-result shape. The Normalize-Kubescape normalizer
    downstream converts the per-control output into v2 FindingRows with ResourceId set
    to the AKS cluster's ARM ID, so each finding folds onto the existing AzureResource
    entity (next to azqr/PSRule/Defender findings on the same cluster).

    Gracefully skips when:
      - kubectl or kubescape is not installed
      - no AKS clusters are in scope
      - `az aks get-credentials` fails (cluster-read permission missing / RBAC denied)

.PARAMETER SubscriptionId
    Azure subscription ID (GUID). Required.

.PARAMETER ClusterArmIds
    Optional pre-filtered list of AKS cluster ARM IDs (overrides ARG discovery).

.PARAMETER OutputPath
    Optional directory for per-cluster raw kubescape JSON (for audit).

.PARAMETER KubeconfigPath
    Optional path to an existing kubeconfig file. When provided, the wrapper
    skips Azure Resource Graph discovery and `az aks get-credentials`, and
    runs a single kubescape scan against the cluster reachable via this
    kubeconfig (kubeconfig mode). Defaults: $env:KUBECONFIG, then
    $HOME/.kube/config when -KubeContext is supplied without a path.
    The file MUST exist when set explicitly; URLs are rejected.

.PARAMETER KubeContext
    Optional kubeconfig context name. In kubeconfig mode passed to
    kubescape via `--kube-context`. In AKS-discovery mode ignored
    (per-cluster contexts are generated automatically).

.PARAMETER Namespace
    Optional namespace filter forwarded to kubescape via
    `--include-namespaces`. Default empty (scan all namespaces).

.PARAMETER KubeAuthMode
    Auth mode applied to the kubeconfig before each scan. One of:
      Default - use whatever the kubeconfig already provides (current behavior).
      Kubelogin - run `kubelogin convert-kubeconfig` (azurecli flow by
                         default, spn when -KubeloginClientId/-KubeloginTenantId
                         are supplied) so AAD-integrated AKS clusters work.
      WorkloadIdentity - federated identity. Sets AZURE_CLIENT_ID /
                         AZURE_TENANT_ID / AZURE_FEDERATED_TOKEN_FILE in
                         process scope and converts the kubeconfig to use
                         `-l workloadidentity`. Designed for in-cluster runs;
                         locally, supply -WorkloadIdentityServiceAccountToken
                         as a path to a federated token file.

.PARAMETER KubeloginServerId
    AAD server (audience) ID for the AKS API. Optional; defaults inferred by kubelogin.

.PARAMETER KubeloginClientId
    AAD client ID for the kubelogin spn flow. Pass with -KubeloginTenantId.

.PARAMETER KubeloginTenantId
    AAD tenant ID for the kubelogin spn flow. Pass with -KubeloginClientId.

.PARAMETER WorkloadIdentityClientId
    AAD client ID of the federated workload identity. GUID. Required for WorkloadIdentity mode.

.PARAMETER WorkloadIdentityTenantId
    AAD tenant ID for the federated workload identity. GUID. Required for WorkloadIdentity mode.

.PARAMETER WorkloadIdentityServiceAccountToken
    Either the path to a federated token file (`/var/run/secrets/azure/tokens/azure-identity-token`
    in pod) or the literal token value (will be written to a temp file with
    restrictive ACLs and cleaned up).
#>

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param (
    [Parameter(Mandatory)] [string] $SubscriptionId,
    [string[]] $ClusterArmIds,
    [string] $OutputPath,
    [string] $KubeconfigPath,
    [string] $KubeContext,
    [string] $Namespace = '',
    [ValidateSet('Default', 'Kubelogin', 'WorkloadIdentity')]
    [string] $KubeAuthMode = 'Default',
    [string] $KubeloginServerId,
    [string] $KubeloginClientId,
    [string] $KubeloginTenantId,
    [string] $WorkloadIdentityClientId,
    [string] $WorkloadIdentityTenantId,
    [string] $WorkloadIdentityServiceAccountToken
)

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

$retryPath = Join-Path $PSScriptRoot 'shared' 'Retry.ps1'
if (Test-Path $retryPath) { . $retryPath }
if (-not (Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue)) {
    function Invoke-WithRetry { param([scriptblock]$ScriptBlock, [int]$MaxAttempts = 3) & $ScriptBlock }
}

$sanitizePath = Join-Path $PSScriptRoot 'shared' 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param([string]$Text) return $Text }
}
$missingToolPath = Join-Path $PSScriptRoot 'shared' 'MissingTool.ps1'
if (Test-Path $missingToolPath) { . $missingToolPath }
if (-not (Get-Command Write-MissingToolNotice -ErrorAction SilentlyContinue)) {
    function Write-MissingToolNotice { param([string]$Tool, [string]$Message) Write-Warning $Message }
}

$errorsPath = Join-Path $PSScriptRoot 'shared' 'Errors.ps1'
if (Test-Path $errorsPath) { . $errorsPath }
if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) {
    function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } }
}
if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) {
    function Format-FindingErrorMessage {
        param([Parameter(Mandatory)]$FindingError)
        $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason
        if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" }
        return $line
    }
}

$kubeAuthPath = Join-Path $PSScriptRoot 'shared' 'KubeAuth.ps1'
if (Test-Path $kubeAuthPath) { . $kubeAuthPath }
$aksDiscoveryPath = Join-Path $PSScriptRoot 'shared' 'AksDiscovery.ps1'
if (Test-Path $aksDiscoveryPath) { . $aksDiscoveryPath }
# Bootstrap Invoke-WithTimeout for CLI timeout protection
$cliTimeoutPath = Join-Path $PSScriptRoot 'shared' 'CliTimeout.ps1'
if (Test-Path $cliTimeoutPath) { . $cliTimeoutPath }

$envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1'
if (Test-Path $envelopePath) { . $envelopePath }
if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } }
$result = [ordered]@{
    SchemaVersion = '1.0'
    Source        = 'kubescape'
    Status        = 'Success'
    Message       = ''
    Findings      = @()
    Diagnostics   = @()
    Subscription  = $SubscriptionId
    Timestamp     = (Get-Date).ToUniversalTime().ToString('o')
}

# Determine auth mode early. Explicit -KubeconfigPath (or just -KubeContext)
# enables "kubeconfig mode" (BYO cluster, no AKS discovery / get-credentials).
$kubeconfigModeRequested = $PSBoundParameters.ContainsKey('KubeconfigPath') -or `
                           $PSBoundParameters.ContainsKey('KubeContext')

# Validate KubeAuthMode prerequisites up front so we fail fast before any
# per-cluster work is attempted. Default mode is a no-op.
try {
    Assert-KubeAuthMode `
        -Mode $KubeAuthMode `
        -KubeloginServerId $KubeloginServerId `
        -KubeloginClientId $KubeloginClientId `
        -KubeloginTenantId $KubeloginTenantId `
        -WorkloadIdentityClientId $WorkloadIdentityClientId `
        -WorkloadIdentityTenantId $WorkloadIdentityTenantId `
        -WorkloadIdentityServiceAccountToken $WorkloadIdentityServiceAccountToken
} catch {
    $authErr = New-FindingError -Source 'wrapper:kubescape' -Category 'InvalidParameter' -Reason (Remove-Credentials -Text "$_") -Remediation 'Check KubeAuthMode parameters.'
    return (New-WrapperEnvelope -Source 'kubescape' -Status 'Failed' -Message (Format-FindingErrorMessage $authErr) -FindingErrors @($authErr))
}

$resolvedKubeconfig = $null
if ($PSBoundParameters.ContainsKey('KubeconfigPath')) {
    if ([string]::IsNullOrWhiteSpace($KubeconfigPath)) {
        $valErr = New-FindingError -Source 'wrapper:kubescape' -Category 'InvalidParameter' -Reason 'Invalid -KubeconfigPath: value is empty.' -Remediation 'Provide a non-empty local file path via -KubeconfigPath.'
        return (New-WrapperEnvelope -Source 'kubescape' -Status 'Failed' -Message (Format-FindingErrorMessage $valErr) -FindingErrors @($valErr))
    }
    if ($KubeconfigPath -match '^[a-z][a-z0-9+.-]*://') {
        $valErr = New-FindingError -Source 'wrapper:kubescape' -Category 'InvalidParameter' -Reason "Invalid -KubeconfigPath '$(Remove-Credentials -Text $KubeconfigPath)': URLs are not accepted; provide a local file path." -Remediation 'Use a local kubeconfig file path, not a URL.'
        return (New-WrapperEnvelope -Source 'kubescape' -Status 'Failed' -Message (Format-FindingErrorMessage $valErr) -FindingErrors @($valErr))
    }
    if (-not (Test-Path -LiteralPath $KubeconfigPath -PathType Leaf)) {
        $valErr = New-FindingError -Source 'wrapper:kubescape' -Category 'NotFound' -Reason "Invalid -KubeconfigPath '$(Remove-Credentials -Text $KubeconfigPath)': file does not exist." -Remediation 'Provide an existing kubeconfig file path via -KubeconfigPath.'
        return (New-WrapperEnvelope -Source 'kubescape' -Status 'Failed' -Message (Format-FindingErrorMessage $valErr) -FindingErrors @($valErr))
    }
    $resolvedKubeconfig = (Resolve-Path -LiteralPath $KubeconfigPath).ProviderPath
} elseif ($kubeconfigModeRequested) {
    # -KubeContext supplied but no -KubeconfigPath: fall back to env / default.
    $candidate = if ($env:KUBECONFIG) { $env:KUBECONFIG } else { Join-Path $HOME '.kube' 'config' }
    if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) {
        $valErr = New-FindingError -Source 'wrapper:kubescape' -Category 'NotFound' -Reason "Invalid kubeconfig: -KubeContext was supplied but no kubeconfig found at '$(Remove-Credentials -Text $candidate)'. Set -KubeconfigPath or `$env:KUBECONFIG." -Remediation 'Set -KubeconfigPath or ensure $env:KUBECONFIG points to an existing kubeconfig file.'
        return (New-WrapperEnvelope -Source 'kubescape' -Status 'Failed' -Message (Format-FindingErrorMessage $valErr) -FindingErrors @($valErr))
    }
    $resolvedKubeconfig = (Resolve-Path -LiteralPath $candidate).ProviderPath
}

# --- Tool prereqs ---
if (-not (Get-Command kubescape -ErrorAction SilentlyContinue)) {
    $missingMessage = 'kubescape is not installed. Skipping Kubescape scan. Install via: winget install ARMO.kubescape | brew install kubescape | curl -s https://raw.githubusercontent.com/kubescape/kubescape/master/install.sh | /bin/bash'
    Write-MissingToolNotice -Tool 'kubescape' -Message $missingMessage
    $result.Status  = 'Skipped'
    $result.Message = 'kubescape CLI not installed. Install via: winget install ARMO.kubescape | brew install kubescape | curl -s https://raw.githubusercontent.com/kubescape/kubescape/master/install.sh | /bin/bash'
    $result.Diagnostics = @(
        [PSCustomObject]@{
            Code    = 'MissingTool'
            Tool    = 'kubescape'
            Message = $missingMessage
        }
    )
    return [pscustomobject]$result
}
if (-not (Get-Command kubectl -ErrorAction SilentlyContinue)) {
    $result.Status  = 'Skipped'
    $result.Message = 'kubectl not installed. kubescape requires kubectl to reach cluster API.'
    return [pscustomobject]$result
}
if (-not $kubeconfigModeRequested -and -not (Get-Command az -ErrorAction SilentlyContinue)) {
    $result.Status  = 'Skipped'
    $result.Message = 'az CLI not installed. Required to populate AKS kubeconfig context (skip by passing -KubeconfigPath).'
    return [pscustomobject]$result
}

function ConvertTo-KubescapeStringArray {
    param(
        [Parameter(ValueFromPipeline)]
        [object] $Value
    )

    $values = [System.Collections.Generic.List[string]]::new()
    foreach ($item in @($Value)) {
        if ($null -eq $item) { continue }
        if ($item -is [string]) {
            if (-not [string]::IsNullOrWhiteSpace($item)) { $values.Add($item.Trim()) }
            continue
        }
        if ($item -is [System.Collections.IEnumerable] -and $item -isnot [string]) {
            foreach ($nested in @($item)) {
                if ($nested -is [string] -and -not [string]::IsNullOrWhiteSpace($nested)) {
                    $values.Add($nested.Trim())
                }
            }
            continue
        }
        $asString = [string]$item
        if (-not [string]::IsNullOrWhiteSpace($asString)) { $values.Add($asString.Trim()) }
    }

    return @($values | Select-Object -Unique)
}

function Get-KubescapeField {
    param(
        [Parameter(Mandatory)]
        [object] $Object,
        [Parameter(Mandatory)]
        [string[]] $Candidates
    )

    foreach ($candidate in $Candidates) {
        $prop = $Object.PSObject.Properties[$candidate]
        if ($prop -and $null -ne $prop.Value) { return $prop.Value }
    }
    return $null
}

function Get-KubescapeFrameworks {
    param(
        [Parameter(Mandatory)]
        [object] $Control,
        [Parameter(Mandatory)]
        [string] $ControlId
    )

    $rawFrameworks = Get-KubescapeField -Object $Control -Candidates @('frameworks', 'Frameworks', 'frameworkNames', 'FrameworkNames', 'framework', 'Framework')
    $entries = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($framework in @($rawFrameworks)) {
        if ($null -eq $framework) { continue }
        $name = ''
        $controls = @($ControlId)
        if ($framework -is [string]) {
            $name = $framework.Trim()
        } else {
            $name = [string](Get-KubescapeField -Object $framework -Candidates @('name', 'Name', 'framework', 'Framework', 'kind', 'Kind'))
            $rawControls = Get-KubescapeField -Object $framework -Candidates @('controls', 'Controls', 'controlIds', 'ControlIds', 'controlId', 'ControlId')
            $normalizedControls = ConvertTo-KubescapeStringArray -Value $rawControls
            if (@($normalizedControls).Count -gt 0) { $controls = @($normalizedControls) }
        }

        if ([string]::IsNullOrWhiteSpace($name)) { continue }
        $entries.Add(@{
                Name      = $name
                Controls  = @($controls)
                ControlId = $ControlId
            })
    }

    return @($entries | Group-Object Name | ForEach-Object { $_.Group[0] })
}

function Get-KubescapeToolVersion {
    try {
        $versionOutput = & kubescape --version 2>&1
        $line = @($versionOutput | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1)
        if (@($line).Count -gt 0) { return $line[0].Trim() }
    } catch {} # best-effort: kubescape CLI not installed; ToolVersion stays empty
    return ''
}

$toolVersion = Get-KubescapeToolVersion

# --- Discover AKS clusters via ARG (unless explicit list provided, or kubeconfig mode) ---
$clusters = @()
if ($kubeconfigModeRequested) {
    # Synthetic single "cluster" backed by the user-supplied kubeconfig.
    $synthName = if ($KubeContext) { $KubeContext } else { 'kubeconfig-default' }
    $synthId   = "kubeconfig:$synthName"
    $clusters += [pscustomobject]@{
        id              = $synthId
        resourceGroup   = ''
        name            = $synthName
        kubeconfigPath  = $resolvedKubeconfig
        kubeContext     = $KubeContext
        kubeconfigOwned = $false   # do NOT delete user-supplied kubeconfig
    }
} elseif ($ClusterArmIds -and $ClusterArmIds.Count -gt 0) {
    try {
        $clusters = @(Get-AksClustersInScope -SubscriptionId $SubscriptionId -ClusterArmIds $ClusterArmIds)
    } catch {
        $result.Status  = 'Failed'
        $result.Message = "ARG discovery failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))"
        return [pscustomobject]$result
    }
} else {
    try {
        $clusters = @(Get-AksClustersInScope -SubscriptionId $SubscriptionId)
    } catch {
        $result.Status  = 'Failed'
        $result.Message = "ARG discovery failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))"
        return [pscustomobject]$result
    }
}

if (-not $clusters -or @($clusters).Count -eq 0) {
    $result.Status  = 'Skipped'
    $result.Message = 'No AKS managed clusters in scope.'
    return [pscustomobject]$result
}

$findings = [System.Collections.Generic.List[object]]::new()
$scanned  = 0
$failed   = 0

if ($OutputPath -and -not (Test-Path $OutputPath)) {
    try { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } catch {}
}

foreach ($cluster in $clusters) {
    $isKubeconfigMode = $false
    if ($cluster.PSObject.Properties['kubeconfigPath'] -and $cluster.kubeconfigPath) {
        $isKubeconfigMode = $true
    }
    $contextForScan = $null
    $tmpKubeconfig  = $null
    if ($isKubeconfigMode) {
        $tmpKubeconfig  = $cluster.kubeconfigPath
        $contextForScan = $cluster.kubeContext
    } else {
        $context = "ks-$($cluster.name)-$([guid]::NewGuid().ToString('N').Substring(0,8))"
        $contextForScan = $context
    }
    $authPrep = $null
    try {
        if (-not $isKubeconfigMode) {
            if (-not $PSCmdlet.ShouldProcess([string]$cluster.name, 'Run kubescape (az aks get-credentials + scan)')) {
                continue
            }
            # Isolated kubeconfig context per cluster - avoid cross-cluster pollution.
            $tmpKubeconfig = Join-Path ([System.IO.Path]::GetTempPath()) "kubeconfig-$context.yaml"
            $azArgs = @('aks', 'get-credentials',
                        '--subscription', $SubscriptionId,
                        '--resource-group', $cluster.resourceGroup,
                        '--name', $cluster.name,
                        '--file', $tmpKubeconfig,
                        '--context', $context,
                        '--overwrite-existing',
                        '--only-show-errors')
            & az @azArgs 2>&1 | Out-Null
            if ($LASTEXITCODE -ne 0) {
                $failed++
                continue
            }
        }

        $rawFile = if ($OutputPath) {
            Join-Path $OutputPath "kubescape-$($cluster.name)-$(Get-Date -Format yyyyMMddHHmmss).json"
        } else {
            Join-Path ([System.IO.Path]::GetTempPath()) "kubescape-$([guid]::NewGuid().ToString('N').Substring(0,8)).json"
        }

        $env:KUBECONFIG = $tmpKubeconfig

        # Apply KubeAuthMode (kubelogin convert / workload identity env vars)
        # AFTER the kubeconfig is materialized but BEFORE we hand it to
        # kubescape. The helper copies the kubeconfig when it is BYO so we
        # never mutate the user's file. In wrapper-owned mode (az aks
        # get-credentials temp), we convert in place.
        if ($KubeAuthMode -ne 'Default') {
            $kubeconfigOwned = -not $isKubeconfigMode
            $authPrep = Initialize-KubeAuth `
                -Mode $KubeAuthMode `
                -KubeconfigPath $tmpKubeconfig `
                -KubeconfigOwned:$kubeconfigOwned `
                -KubeContext $contextForScan `
                -KubeloginServerId $KubeloginServerId `
                -KubeloginClientId $KubeloginClientId `
                -KubeloginTenantId $KubeloginTenantId `
                -WorkloadIdentityClientId $WorkloadIdentityClientId `
                -WorkloadIdentityTenantId $WorkloadIdentityTenantId `
                -WorkloadIdentityServiceAccountToken $WorkloadIdentityServiceAccountToken
            $env:KUBECONFIG = $authPrep.KubeconfigPath
        }

        $ksArgs = @('scan', '--format', 'json', '--output', $rawFile, '--format-version', 'v2')
        if ($contextForScan) { $ksArgs += @('--kube-context', $contextForScan) }
        if ($Namespace)      { $ksArgs += @('--include-namespaces', $Namespace) }
        $ksExec = Invoke-WithTimeout -Command 'kubescape' -Arguments $ksArgs -TimeoutSec 300
        if ($ksExec.Output) { Write-Verbose "kubescape output: $($ksExec.Output)" }
        $scanExit = [int]$ksExec.ExitCode

        if ((Test-Path $rawFile) -and ((Get-Item $rawFile).Length -gt 0)) {
            $raw = Get-Content $rawFile -Raw | ConvertFrom-Json -Depth 30
            if ($raw.summaryDetails -and $raw.summaryDetails.controls) {
                $controls = $raw.summaryDetails.controls
                foreach ($ctrlProp in $controls.PSObject.Properties) {
                    $c = $ctrlProp.Value
                    $status = ''
                    try { $status = [string]$c.status.status } catch {}
                    if ($status -eq 'passed' -or $status -eq 'skipped') { continue }
                    $sev = 'Medium'
                    try { $sev = switch ([int]$c.scoreFactor) {
                        { $_ -ge 9 } { 'Critical' }
                        { $_ -ge 7 } { 'High' }
                        { $_ -ge 4 } { 'Medium' }
                        default      { 'Low' }
                    } } catch {}
                    $ctrlId = $ctrlProp.Name
                    $ctrlName = ''
                    try { $ctrlName = [string]$c.name } catch {}
                    $frameworks = Get-KubescapeFrameworks -Control $c -ControlId $ctrlId
                    $baselineTags = @($frameworks | ForEach-Object {
                            $tag = ([string]$_.Name).ToLowerInvariant() -replace '[^a-z0-9]+', ''
                            if (-not [string]::IsNullOrWhiteSpace($tag)) { $tag }
                        } | Select-Object -Unique)
                    $mitreTactics = ConvertTo-KubescapeStringArray -Value (Get-KubescapeField -Object $c -Candidates @('mitreTactics', 'MitreTactics', 'tactics', 'Tactics'))
                    $mitreTechniques = ConvertTo-KubescapeStringArray -Value (Get-KubescapeField -Object $c -Candidates @('mitreTechniques', 'MitreTechniques', 'techniques', 'Techniques'))
                    $mitre = Get-KubescapeField -Object $c -Candidates @('mitre', 'Mitre')
                    if (@($mitreTactics).Count -eq 0 -and $mitre) {
                        $mitreTactics = ConvertTo-KubescapeStringArray -Value (Get-KubescapeField -Object $mitre -Candidates @('tactics', 'Tactics', 'mitreTactics', 'MitreTactics'))
                    }
                    if (@($mitreTechniques).Count -eq 0 -and $mitre) {
                        $mitreTechniques = ConvertTo-KubescapeStringArray -Value (Get-KubescapeField -Object $mitre -Candidates @('techniques', 'Techniques', 'mitreTechniques', 'MitreTechniques'))
                    }
                    $learnMore = [string](Get-KubescapeField -Object $c -Candidates @('controlDocUrl', 'ControlDocUrl', 'docUrl', 'DocUrl', 'learnMoreUrl', 'LearnMoreUrl'))
                    if ([string]::IsNullOrWhiteSpace($learnMore)) { $learnMore = "https://hub.armosec.io/docs/$($ctrlId.ToLowerInvariant())" }
                    $findings.Add([pscustomobject]@{
                        Id           = "kubescape/$($cluster.id)/$ctrlId"
                        Source       = 'kubescape'
                        Category     = 'KubernetesPosture'
                        Severity     = $sev
                        Compliant    = $false
                        Title        = "${ctrlId}: $ctrlName"
                        Detail       = "kubescape control failed on AKS cluster $($cluster.name). status=$status"
                        Remediation  = "Review kubescape raw output for control $ctrlId and follow CIS K8s Benchmark guidance."
                        ResourceId   = $cluster.id
                        ControlId    = $ctrlId
                        LearnMoreUrl = $learnMore
                        Pillar       = 'Security'
                        Frameworks   = @($frameworks)
                        MitreTactics = @($mitreTactics)
                        MitreTechniques = @($mitreTechniques)
                        BaselineTags = @($baselineTags)
                        ToolVersion  = $toolVersion
                    }) | Out-Null
                }
            }
            $scanned++
        } else {
            $failed++
        }
    } catch {
        $failed++
        Write-Warning "kubescape scan failed for cluster $($cluster.name): $(Remove-Credentials -Text ([string]$_.Exception.Message))"
    } finally {
        # Remove the isolated kubeconfig to avoid leaking cluster auth.
        # In kubeconfig mode the path was supplied by the caller; do not delete it.
        if ($authPrep -and $authPrep.Cleanup) {
            try { & $authPrep.Cleanup } catch {}
        }
        if (-not $isKubeconfigMode -and $tmpKubeconfig -and (Test-Path $tmpKubeconfig)) {
            try { Remove-Item $tmpKubeconfig -Force -ErrorAction SilentlyContinue } catch {}
        }
        if ($env:KUBECONFIG) { Remove-Item Env:\KUBECONFIG -ErrorAction SilentlyContinue }
    }
}

$result.Findings = @($findings)
$result.Message  = "Scanned $scanned AKS cluster(s); $failed failed; emitted $($findings.Count) non-passing control findings."
if ($scanned -eq 0 -and $failed -gt 0) {
    $result.Status = 'Failed'
} elseif ($scanned -gt 0 -and $failed -gt 0) {
    $result.Status = 'PartialSuccess'
}

return [pscustomobject]$result