modules/normalizers/Normalize-Kubescape.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for kubescape wrapper output.
.DESCRIPTION
    Converts v1 kubescape wrapper output to v2 FindingRows.
    Each non-passing control becomes a FindingRow on the AKS cluster's canonical ARM ID
    (EntityType=AzureResource, Platform=Azure) so kubescape findings fold onto the same
    entity as azqr/PSRule/Defender recommendations for that cluster.
    ControlId (e.g. C-0001, CIS-5.1.3) surfaces on Controls[] for framework mapping.
#>

[CmdletBinding()]
param ()

. "$PSScriptRoot\..\shared\Schema.ps1"
. "$PSScriptRoot\..\shared\Canonicalize.ps1"

function Normalize-Kubescape {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [PSCustomObject] $ToolResult
    )

    if ($ToolResult.Status -ne 'Success' -or -not $ToolResult.Findings) {
        return @()
    }

    $runId = [guid]::NewGuid().ToString()
    $normalized = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($f in $ToolResult.Findings) {
        $rawId = if ($f.PSObject.Properties['ResourceId'] -and $f.ResourceId) { [string]$f.ResourceId } else { '' }
        if (-not $rawId) { continue }

        $subId = ''
        $rg    = ''
        if ($rawId -match '/subscriptions/([^/]+)') { $subId = $Matches[1] }
        if ($rawId -match '/resourceGroups/([^/]+)') { $rg    = $Matches[1] }

        try   { $canonicalId = (ConvertTo-CanonicalEntityId -RawId $rawId -EntityType 'AzureResource').CanonicalId }
        catch { $canonicalId = $rawId.ToLowerInvariant() }

        $sevRaw = if ($f.PSObject.Properties['Severity'] -and $f.Severity) { [string]$f.Severity } else { 'Medium' }
        $sev = switch -Regex ($sevRaw) {
            '^(?i)critical$' { 'Critical' }
            '^(?i)high$'     { 'High' }
            '^(?i)medium$'   { 'Medium' }
            '^(?i)low$'      { 'Low' }
            '^(?i)info'      { 'Info' }
            default          { 'Medium' }
        }

        $findingId = if ($f.PSObject.Properties['Id'] -and $f.Id) { [string]$f.Id } else { [guid]::NewGuid().ToString() }
        $remediation = if ($f.PSObject.Properties['Remediation']) { [string]$f.Remediation } else { '' }
        $controlId = if ($f.PSObject.Properties['ControlId'] -and $f.ControlId) { [string]$f.ControlId } else { '' }
        $ruleId = if (-not [string]::IsNullOrWhiteSpace($controlId)) { "kubescape:$controlId" } else { '' }
        $frameworks = @()
        if ($f.PSObject.Properties['Frameworks'] -and $f.Frameworks) {
            $frameworks = @($f.Frameworks | ForEach-Object {
                    if ($null -eq $_) { return }
                    $name = [string]$_.Name
                    if ([string]::IsNullOrWhiteSpace($name)) { return }
                    $controls = @($_.Controls)
                    if (-not $controls -or @($controls).Count -eq 0) {
                        if (-not [string]::IsNullOrWhiteSpace($controlId)) { $controls = @($controlId) } else { $controls = @() }
                    }
                    @{
                        Name      = $name
                        ControlId = if (-not [string]::IsNullOrWhiteSpace($controlId)) { $controlId } else { [string]$_.ControlId }
                        Controls  = @($controls)
                    }
                })
        }
        $baselineTags = if ($f.PSObject.Properties['BaselineTags']) { @([string[]]$f.BaselineTags) } else { @() }
        $mitreTactics = if ($f.PSObject.Properties['MitreTactics']) { @([string[]]$f.MitreTactics) } else { @() }
        $mitreTechniques = if ($f.PSObject.Properties['MitreTechniques']) { @([string[]]$f.MitreTechniques) } else { @() }
        $evidenceUris = if ($f.PSObject.Properties['EvidenceUris']) { @([string[]]$f.EvidenceUris) } else { @() }
        $learnMoreUrl = if ($f.PSObject.Properties['LearnMoreUrl']) { [string]$f.LearnMoreUrl } else { '' }
        if (@($evidenceUris).Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($learnMoreUrl)) {
            $evidenceUris = @($learnMoreUrl)
        }
        if (@($evidenceUris).Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($controlId)) {
            $evidenceUris = @("https://hub.armosec.io/docs/$($controlId.ToLowerInvariant())")
        }
        $toolVersion = if ($f.PSObject.Properties['ToolVersion']) { [string]$f.ToolVersion } else { '' }
        $pillar = if ($f.PSObject.Properties['Pillar'] -and $f.Pillar) { [string]$f.Pillar } else { 'Security' }
        $controls = if (-not [string]::IsNullOrWhiteSpace($controlId)) { @($controlId) } else { @() }

        # Track D enrichment (#432b): derive Impact/Effort, surface DeepLinkUrl,
        # build RemediationSnippets from the prose Remediation, pass through ScoreDelta,
        # and attach subscription EntityRef for cross-source folding.
        $impact = if ($f.PSObject.Properties['Impact'] -and $f.Impact) { [string]$f.Impact } else {
            switch ($sev) { 'Critical' { 'High' } 'High' { 'High' } 'Medium' { 'Medium' } default { 'Low' } }
        }
        $effort = if ($f.PSObject.Properties['Effort'] -and $f.Effort) { [string]$f.Effort } else {
            switch ($sev) { 'Critical' { 'High' } 'High' { 'Medium' } 'Medium' { 'Medium' } default { 'Low' } }
        }
        $deepLinkUrl = if ($f.PSObject.Properties['DeepLinkUrl'] -and $f.DeepLinkUrl) {
            [string]$f.DeepLinkUrl
        } elseif (-not [string]::IsNullOrWhiteSpace($controlId)) {
            "https://hub.armosec.io/docs/$($controlId.ToLowerInvariant())"
        } else {
            ''
        }
        $remediationSnippets = @()
        if ($f.PSObject.Properties['RemediationSnippets'] -and $f.RemediationSnippets) {
            $remediationSnippets = @($f.RemediationSnippets | ForEach-Object {
                    if ($null -eq $_) { return }
                    if ($_ -is [hashtable]) { return $_ }
                    $h = @{}
                    foreach ($p in $_.PSObject.Properties) { $h[$p.Name] = $p.Value }
                    return $h
                })
        }
        if (@($remediationSnippets).Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($remediation)) {
            $remediationSnippets = @(@{ language = 'text'; code = $remediation.Trim() })
        }
        $scoreDelta = $null
        if ($f.PSObject.Properties['ScoreDelta'] -and $null -ne $f.ScoreDelta) {
            try { $scoreDelta = [double]$f.ScoreDelta } catch { $scoreDelta = $null }
        }
        $entityRefs = [System.Collections.Generic.List[string]]::new()
        if ($f.PSObject.Properties['EntityRefs'] -and $f.EntityRefs) {
            foreach ($r in @($f.EntityRefs)) { if (-not [string]::IsNullOrWhiteSpace([string]$r)) { $entityRefs.Add([string]$r) | Out-Null } }
        }
        if ($subId) {
            try {
                $subRef = (ConvertTo-CanonicalEntityId -RawId $subId -EntityType 'Subscription').CanonicalId
                if ($subRef -and $entityRefs -notcontains $subRef) { $entityRefs.Add($subRef) | Out-Null }
            } catch { } # best-effort: malformed subscriptionId; skip enrichment, keep raw finding
        }

        $row = New-FindingRow -Id $findingId `
            -Source 'kubescape'-EntityId $canonicalId -EntityType 'AzureResource' `
            -Title ([string]$f.Title) -RuleId $ruleId -Compliant $false -ProvenanceRunId $runId `
            -Platform 'Azure' -Category 'KubernetesPosture' -Severity $sev `
            -Detail ([string]$f.Detail) -Remediation $remediation `
            -LearnMoreUrl ([string]$f.LearnMoreUrl) -ResourceId $rawId `
            -SubscriptionId $subId -ResourceGroup $rg `
            -Pillar $pillar -Frameworks @($frameworks) -Controls @($controls) `
            -MitreTactics @($mitreTactics) -MitreTechniques @($mitreTechniques) `
            -EvidenceUris @($evidenceUris) -BaselineTags @($baselineTags) `
            -Impact $impact -Effort $effort -DeepLinkUrl $deepLinkUrl `
            -RemediationSnippets @($remediationSnippets) -ScoreDelta $scoreDelta `
            -EntityRefs @($entityRefs) `
            -ToolVersion $toolVersion

        # Skip null rows (validation failed)

        if ($null -ne $row) {

            $normalized.Add($row)

        }
    }

    return @($normalized)
}