modules/Invoke-KubeBench.ps1
|
#requires -Version 7.0 <# .SYNOPSIS Wrapper for kube-bench — node-level CIS checks on AKS worker nodes. .DESCRIPTION Discovers AKS managed clusters in scope via Azure Resource Graph (or accepts explicit -ClusterArmIds), creates an isolated kubeconfig per cluster, applies a temporary kube-bench Job in kube-system, collects logs, and maps FAIL/WARN checks to v1 findings that fold onto the AKS cluster ARM resource ID. Job resources and temporary kubeconfig/manifest files are always cleaned up. .PARAMETER KubeconfigPath Optional path to an existing kubeconfig file. When provided, skips Azure Resource Graph discovery and `az aks get-credentials`, and runs a single kube-bench Job against the cluster reachable via this kubeconfig. The file MUST exist when set explicitly; URLs are rejected. .PARAMETER KubeContext Optional kubeconfig context name passed to `kubectl --context`. .PARAMETER Namespace Namespace where the temporary kube-bench Job is created and logs are collected from. Default 'kube-system'. .PARAMETER KubeAuthMode Auth mode applied to the kubeconfig before kubectl apply / wait / logs. One of Default | Kubelogin | WorkloadIdentity. See docs/consumer/k8s-auth.md. .PARAMETER KubeloginServerId / KubeloginClientId / KubeloginTenantId AAD args for kubelogin convert-kubeconfig. .PARAMETER WorkloadIdentityClientId / WorkloadIdentityTenantId / WorkloadIdentityServiceAccountToken Federated identity args. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param ( [Parameter(Mandatory)] [string] $SubscriptionId, [string[]] $ClusterArmIds, [string] $OutputPath, [ValidateRange(60, 3600)] [int] $JobTimeoutSeconds = 600, [string] $KubeBenchImage = 'aquasec/kube-bench:v0.7.2', [string] $KubeconfigPath, [string] $KubeContext, [string] $Namespace = 'kube-system', [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 } } $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 } } if (-not (Get-Command Write-FindingError -ErrorAction SilentlyContinue)) { function Write-FindingError { param($FindingError) Write-Warning ('[{0}] {1}: {2}{3}' -f $FindingError.Source, $FindingError.Category, $FindingError.Reason, $(if ($FindingError.Remediation) { ' Action: ' + $FindingError.Remediation } else { '' })) } } $kubeAuthPath = Join-Path $PSScriptRoot 'shared' 'KubeAuth.ps1' if (Test-Path $kubeAuthPath) { . $kubeAuthPath } # 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) } } } # Validate KubeAuthMode prerequisites up front so misconfigured invocations # fail before any cluster discovery / kubectl call. 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:kube-bench' -Category 'InvalidParameter' -Reason (Remove-Credentials -Text "$_") -Remediation 'Check KubeAuthMode parameters.' return (New-WrapperEnvelope -Source 'kube-bench' -Status 'Failed' -Message (Format-FindingErrorMessage $authErr) -FindingErrors @($authErr)) } function ConvertFrom-KubeBenchLogJson { param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $null } try { return ($Text | ConvertFrom-Json -Depth 40 -ErrorAction Stop) } catch {} $start = $Text.IndexOf('{') $end = $Text.LastIndexOf('}') if ($start -lt 0 -or $end -le $start) { return $null } try { return ($Text.Substring($start, ($end - $start + 1)) | ConvertFrom-Json -Depth 40 -ErrorAction Stop) } catch {} return $null } function Get-KubeBenchFailedChecks { param([object]$Report) $items = [System.Collections.Generic.List[object]]::new() if (-not $Report) { return @($items) } function Get-KubeBenchValue { param( [object]$Object, [string[]]$Candidates ) if (-not $Object) { return $null } foreach ($candidate in $Candidates) { if ($Object.PSObject.Properties[$candidate]) { return $Object.$candidate } } return $null } $controls = @() if ($Report.PSObject.Properties['Controls']) { $controls = @($Report.Controls) } elseif ($Report.PSObject.Properties['controls']) { $controls = @($Report.controls) } foreach ($control in $controls) { $tests = @() if ($control -and $control.PSObject.Properties['tests']) { $tests = @($control.tests) } foreach ($test in $tests) { $results = @() if ($test -and $test.PSObject.Properties['results']) { $results = @($test.results) } foreach ($r in $results) { $status = if ($r.PSObject.Properties['status']) { [string]$r.status } else { '' } if ($status -notmatch '^(?i)(FAIL|WARN)$') { continue } $testNumber = if ($r.PSObject.Properties['test_number']) { [string]$r.test_number } else { '' } $testDesc = if ($r.PSObject.Properties['test_desc']) { [string]$r.test_desc } else { '' } $section = if ($test.PSObject.Properties['section']) { [string]$test.section } else { '' } $remediation = if ($r.PSObject.Properties['remediation']) { [string]$r.remediation } else { '' } $audit = if ($r.PSObject.Properties['audit']) { [string]$r.audit } else { '' } $nodeRef = [string](Get-KubeBenchValue -Object $r -Candidates @('node', 'node_name', 'nodeName', 'node_id', 'nodeId', 'target')) if ([string]::IsNullOrWhiteSpace($nodeRef)) { $nodeRef = [string](Get-KubeBenchValue -Object $test -Candidates @('node', 'node_name', 'nodeName', 'node_id', 'nodeId', 'target')) } if ([string]::IsNullOrWhiteSpace($nodeRef)) { $nodeRef = [string](Get-KubeBenchValue -Object $control -Candidates @('node', 'node_name', 'nodeName', 'node_id', 'nodeId', 'target')) } $controlId = if ($testNumber) { $testNumber } elseif ($section) { $section } else { [guid]::NewGuid().ToString() } $title = if ($testNumber -and $testDesc) { "${testNumber}: $testDesc" } elseif ($testDesc) { $testDesc } else { "kube-bench check $controlId" } $detail = if ($audit) { "kube-bench $status check. section=$section; audit=$audit" } else { "kube-bench $status check. section=$section" } $severity = if ($status -match '^(?i)FAIL$') { 'High' } else { 'Medium' } $items.Add([pscustomobject]@{ ControlId = $controlId Title = $title Status = $status.ToUpperInvariant() Severity = $severity Detail = $detail Remediation = $remediation NodeRef = $nodeRef }) | Out-Null } } } return @($items) } function Resolve-KubeBenchToolVersion { param( [Parameter(Mandatory)][string]$Image ) if ([string]::IsNullOrWhiteSpace($Image)) { return '' } $clean = $Image.Trim() $withoutDigest = $clean.Split('@')[0] $parts = $withoutDigest.Split(':') if ($parts.Count -gt 1 -and -not [string]::IsNullOrWhiteSpace($parts[-1])) { return $parts[-1] } return $withoutDigest } function Resolve-KubeBenchRemediationLanguage { param([string]$Remediation) if ([string]::IsNullOrWhiteSpace($Remediation)) { return '' } if ($Remediation -match '(?im)^\s*(apiVersion|kind|metadata|spec)\s*:') { return 'yaml' } return 'bash' } function Resolve-KubeBenchImpact { param([string]$Severity) switch -Regex ($Severity) { '^(?i)(critical|high)$' { return 'High' } '^(?i)medium$' { return 'Medium' } default { return 'Low' } } } function Get-KubeBenchFrameworks { param( [string]$ControlId, [string]$ResourceId ) if ([string]::IsNullOrWhiteSpace($ControlId)) { return @() } $frameworkNames = [System.Collections.Generic.List[string]]::new() $frameworkNames.Add('CIS Kubernetes Benchmark') | Out-Null if (-not [string]::IsNullOrWhiteSpace($ResourceId) -and $ResourceId -match '(?i)/providers/microsoft\.containerservice/managedclusters/') { $frameworkNames.Add('CIS-AKS') | Out-Null } elseif (-not [string]::IsNullOrWhiteSpace($ResourceId) -and $ResourceId -match '(?i)/providers/eks') { $frameworkNames.Add('CIS-EKS') | Out-Null } $frameworks = [System.Collections.Generic.List[hashtable]]::new() foreach ($frameworkName in ($frameworkNames | Select-Object -Unique)) { $frameworks.Add(@{ kind = $frameworkName controlId = $ControlId Name = $frameworkName Controls = @($ControlId) }) | Out-Null } return $frameworks.ToArray() } function Get-ShortSha256 { param([Parameter(Mandatory)][string]$InputText) $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($InputText) $hash = $sha.ComputeHash($bytes) return ([System.BitConverter]::ToString($hash) -replace '-', '').Substring(0, 12).ToLowerInvariant() } finally { $sha.Dispose() } } $result = [ordered]@{ SchemaVersion = '1.0' Source = 'kube-bench' Status = 'Success' Message = '' Findings = @() Subscription = $SubscriptionId Timestamp = (Get-Date).ToUniversalTime().ToString('o') } $toolVersion = Resolve-KubeBenchToolVersion -Image $KubeBenchImage $kubeconfigModeRequested = $PSBoundParameters.ContainsKey('KubeconfigPath') -or ` $PSBoundParameters.ContainsKey('KubeContext') $resolvedKubeconfig = $null if ($PSBoundParameters.ContainsKey('KubeconfigPath')) { if ([string]::IsNullOrWhiteSpace($KubeconfigPath)) { $valErr = New-FindingError -Source 'wrapper:kube-bench' -Category 'InvalidParameter' -Reason 'Invalid -KubeconfigPath: value is empty.' -Remediation 'Provide a non-empty local file path via -KubeconfigPath.' return (New-WrapperEnvelope -Source 'kube-bench' -Status 'Failed' -Message (Format-FindingErrorMessage $valErr) -FindingErrors @($valErr)) } if ($KubeconfigPath -match '^[a-z][a-z0-9+.-]*://') { $valErr = New-FindingError -Source 'wrapper:kube-bench' -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 'kube-bench' -Status 'Failed' -Message (Format-FindingErrorMessage $valErr) -FindingErrors @($valErr)) } if (-not (Test-Path -LiteralPath $KubeconfigPath -PathType Leaf)) { $valErr = New-FindingError -Source 'wrapper:kube-bench' -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 'kube-bench' -Status 'Failed' -Message (Format-FindingErrorMessage $valErr) -FindingErrors @($valErr)) } $resolvedKubeconfig = (Resolve-Path -LiteralPath $KubeconfigPath).ProviderPath } elseif ($kubeconfigModeRequested) { $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:kube-bench' -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 'kube-bench' -Status 'Failed' -Message (Format-FindingErrorMessage $valErr) -FindingErrors @($valErr)) } $resolvedKubeconfig = (Resolve-Path -LiteralPath $candidate).ProviderPath } if (-not (Get-Command kubectl -ErrorAction SilentlyContinue)) { $result.Status = 'Skipped' $result.Message = 'kubectl not installed. kube-bench runtime job requires kubectl to access AKS.' 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 } $clusters = @() if ($kubeconfigModeRequested) { $synthName = if ($KubeContext) { $KubeContext } else { 'kubeconfig-default' } $clusters += [pscustomobject]@{ id = "kubeconfig:$synthName" resourceGroup = '' name = $synthName kubeconfigPath = $resolvedKubeconfig kubeContext = $KubeContext } } elseif ($ClusterArmIds -and $ClusterArmIds.Count -gt 0) { foreach ($id in $ClusterArmIds) { $rg = if ($id -match '/resourceGroups/([^/]+)') { $Matches[1] } else { '' } $name = Split-Path $id -Leaf $clusters += [pscustomobject]@{ id = $id; resourceGroup = $rg; name = $name } } } else { if (-not (Get-Module -ListAvailable -Name Az.ResourceGraph)) { $result.Status = 'Skipped' $result.Message = 'Az.ResourceGraph module not installed; cannot discover AKS clusters.' return [pscustomobject]$result } Import-Module Az.ResourceGraph -ErrorAction SilentlyContinue try { $query = "Resources | where type =~ 'Microsoft.ContainerService/managedClusters' | where subscriptionId == '$SubscriptionId' | project id, name, resourceGroup" $argResp = Invoke-WithRetry -MaxAttempts 3 -ScriptBlock { Search-AzGraph -Query $using:query -First 200 -ErrorAction Stop } $clusters = @($argResp) } 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 } if ($OutputPath -and -not (Test-Path $OutputPath)) { try { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } catch {} } $findings = [System.Collections.Generic.List[object]]::new() $scanned = 0 $failed = 0 foreach ($cluster in $clusters) { $isKubeconfigMode = $false if ($cluster.PSObject.Properties['kubeconfigPath'] -and $cluster.kubeconfigPath) { $isKubeconfigMode = $true } if (-not $isKubeconfigMode) { # Defense-in-depth: reject resources whose names contain shell metacharacters. # Azure RG names allow [A-Za-z0-9._()-]; AKS cluster names allow [A-Za-z0-9-]. if ($cluster.resourceGroup -notmatch '^[A-Za-z0-9._()-]{1,90}$' -or $cluster.name -notmatch '^[A-Za-z0-9-]{1,63}$') { Write-Warning "Skipping cluster with unsafe name/resourceGroup: $($cluster.name) in $($cluster.resourceGroup)" $failed++ continue } } if ($isKubeconfigMode) { $context = $cluster.kubeContext $tmpKubeconfig = $cluster.kubeconfigPath } else { $context = "kb-$($cluster.name)-$([guid]::NewGuid().ToString('N').Substring(0,8))" $tmpKubeconfig = $null } $jobManifest = $null $jobName = "aa-kube-bench-$([guid]::NewGuid().ToString('N').Substring(0,8))" $jobApplied = $false $rawLogsPath = $null $authPrep = $null try { if (-not $PSCmdlet.ShouldProcess([string]$cluster.name, 'Apply kube-bench Job and collect logs')) { continue } if (-not $isKubeconfigMode) { $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 } } $jobManifest = Join-Path ([System.IO.Path]::GetTempPath()) "kube-bench-$jobName-job.yaml" @" apiVersion: batch/v1 kind: Job metadata: name: $jobName namespace: $Namespace spec: backoffLimit: 0 template: spec: restartPolicy: Never hostPID: true containers: - name: kube-bench image: $KubeBenchImage command: ["kube-bench", "run", "--json"] volumeMounts: - { name: var-lib-kubelet, mountPath: /var/lib/kubelet, readOnly: true } - { name: etc-systemd, mountPath: /etc/systemd, readOnly: true } - { name: lib-systemd, mountPath: /lib/systemd, readOnly: true } - { name: srv-kubernetes, mountPath: /etc/kubernetes, readOnly: true } volumes: - { name: var-lib-kubelet, hostPath: { path: "/var/lib/kubelet" } } - { name: etc-systemd, hostPath: { path: "/etc/systemd" } } - { name: lib-systemd, hostPath: { path: "/lib/systemd" } } - { name: srv-kubernetes, hostPath: { path: "/etc/kubernetes" } } "@ | Set-Content -Path $jobManifest -Encoding utf8 $env:KUBECONFIG = $tmpKubeconfig if ($KubeAuthMode -ne 'Default') { $kubeconfigOwned = -not $isKubeconfigMode $authPrep = Initialize-KubeAuth ` -Mode $KubeAuthMode ` -KubeconfigPath $tmpKubeconfig ` -KubeconfigOwned:$kubeconfigOwned ` -KubeContext $context ` -KubeloginServerId $KubeloginServerId ` -KubeloginClientId $KubeloginClientId ` -KubeloginTenantId $KubeloginTenantId ` -WorkloadIdentityClientId $WorkloadIdentityClientId ` -WorkloadIdentityTenantId $WorkloadIdentityTenantId ` -WorkloadIdentityServiceAccountToken $WorkloadIdentityServiceAccountToken $env:KUBECONFIG = $authPrep.KubeconfigPath } $kctxArgs = @() if ($context) { $kctxArgs += @('--context', $context) } $applyArgs = $kctxArgs + @('apply', '-f', $jobManifest) $applyExec = Invoke-WithTimeout -Command 'kubectl' -Arguments $applyArgs -TimeoutSec 60 if ([int]$applyExec.ExitCode -ne 0) { $failed++ continue } $jobApplied = $true $waitArgs = $kctxArgs + @('-n', $Namespace, 'wait', '--for=condition=complete', "job/$jobName", "--timeout=$($JobTimeoutSeconds)s") $waitExec = Invoke-WithTimeout -Command 'kubectl' -Arguments $waitArgs -TimeoutSec ([int]$JobTimeoutSeconds + 30) if ([int]$waitExec.ExitCode -ne 0) { Write-FindingError (New-FindingError -Source 'wrapper:kube-bench' ` -Category 'TimeoutExceeded' ` -Reason ("kubectl wait did not see job/{0} complete within {1}s on cluster {2} (context={3} namespace={4})." -f $jobName, $JobTimeoutSeconds, $cluster.name, $context, $Namespace) ` -Remediation 'Increase -JobTimeoutSeconds or check cluster scheduler health and node capacity.' ` -Details ("kubectl exit {0}" -f [int]$waitExec.ExitCode)) $failed++ continue } $logsArgs = $kctxArgs + @('-n', $Namespace, 'logs', "job/$jobName") $logsExec = Invoke-WithTimeout -Command 'kubectl' -Arguments $logsArgs -TimeoutSec 120 $kubeBenchLogs = $logsExec.Output if ([int]$logsExec.ExitCode -ne 0) { Write-FindingError (New-FindingError -Source 'wrapper:kube-bench' ` -Category 'IOFailure' ` -Reason ("kubectl logs failed for job/{0} on cluster {1} (context={2} namespace={3})." -f $jobName, $cluster.name, $context, $Namespace) ` -Remediation 'Verify kubeconfig credentials, RBAC for pods/log in the namespace, and that the job pod has not been evicted.' ` -Details ("kubectl exit {0}" -f [int]$logsExec.ExitCode)) $failed++ continue } if ([string]::IsNullOrWhiteSpace($kubeBenchLogs)) { $failed++ continue } if ($OutputPath) { $rawLogsPath = Join-Path $OutputPath "kube-bench-$($cluster.name)-$(Get-Date -Format yyyyMMddHHmmss).json" (Remove-Credentials -Text $kubeBenchLogs) | Set-Content -Path $rawLogsPath -Encoding utf8 } $parsed = ConvertFrom-KubeBenchLogJson -Text $kubeBenchLogs if (-not $parsed) { $failed++ continue } $clusterFindings = @(Get-KubeBenchFailedChecks -Report $parsed) $idx = 0 $clusterKey = Get-ShortSha256 -InputText ([string]$cluster.id) foreach ($f in $clusterFindings) { $idx++ $frameworks = Get-KubeBenchFrameworks -ControlId $f.ControlId -ResourceId ([string]$cluster.id) $baselineTags = @($f.ControlId, $f.Status | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) $remediation = [string]$f.Remediation $snippetLanguage = Resolve-KubeBenchRemediationLanguage -Remediation $remediation $remediationSnippets = @() if (-not [string]::IsNullOrWhiteSpace($remediation) -and -not [string]::IsNullOrWhiteSpace($snippetLanguage)) { $remediationSnippets = @(@{ language = $snippetLanguage content = $remediation }) } $entityRefs = [System.Collections.Generic.List[string]]::new() if (-not [string]::IsNullOrWhiteSpace([string]$cluster.id)) { $entityRefs.Add([string]$cluster.id) | Out-Null } if ($f.PSObject.Properties['NodeRef'] -and -not [string]::IsNullOrWhiteSpace([string]$f.NodeRef)) { $entityRefs.Add([string]$f.NodeRef) | Out-Null } $findings.Add([pscustomobject]@{ Id = "kube-bench/$clusterKey/$($f.ControlId)/$idx" Source = 'kube-bench' Category = 'KubernetesNodeSecurity' Severity = $f.Severity Compliant = $false Title = $f.Title Detail = "$($f.Detail) cluster=$($cluster.name)" Remediation = $f.Remediation ResourceId = $cluster.id ControlId = $f.ControlId Status = $f.Status LearnMoreUrl = 'https://github.com/aquasecurity/kube-bench' DeepLinkUrl = 'https://github.com/aquasecurity/kube-bench' Pillar = 'Security' Impact = Resolve-KubeBenchImpact -Severity ([string]$f.Severity) Frameworks = @($frameworks) BaselineTags = @($baselineTags) RemediationSnippets = @($remediationSnippets) ToolVersion = $toolVersion EntityRefs = @($entityRefs | Select-Object -Unique) }) | Out-Null } $scanned++ } catch { $failed++ Write-Warning "kube-bench scan failed for cluster $($cluster.name): $(Remove-Credentials -Text ([string]$_.Exception.Message))" } finally { if ($jobApplied) { $delArgs = @() if ($context) { $delArgs += @('--context', $context) } & kubectl @delArgs -n $Namespace delete "job/$jobName" --ignore-not-found=true 2>&1 | Out-Null } if ($jobManifest -and (Test-Path $jobManifest)) { try { Remove-Item $jobManifest -Force -ErrorAction SilentlyContinue } catch {} } 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) kube-bench FAIL/WARN finding(s)." 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 |