Private/Get-AACAksConstraint.ps1

function Get-AACAksConstraint {
    <#
    .SYNOPSIS
        Reads the Gatekeeper constraints the Azure Policy add-on installs in
        one AKS cluster - their complete violation counts and a sample of the
        violations - with AKS's run command, no kubectl or network path to
        the cluster needed.
    .DESCRIPTION
        Azure Policy keeps at most 500 non-compliant records per policy and
        cluster; Gatekeeper's status.totalViolations is the full count (the
        AKS Policy Compliance Toolkit's 04-constraint-violations script).
        This runs, in the cluster (POST .../runCommand, then the command's
        result until it finishes, -TimeoutMinutes at most):
          kubectl get <every constraint kind> -o jsonpath=...
        and returns:
          Totals per constraint: Kind, Name, Action (dryrun = Audit,
                      deny, warn), TotalViolations, Assignment, ReferenceId
          Violations the sampled violations: Constraint, Namespace, Object,
                      Message
          Status 'OK', 'NoGatekeeper' (the add-on isn't installed) or
                      why it couldn't run
        A cluster with Entra ID integration needs a token for the AKS
        server app (6dae42f8-4368-4678-94ff-3960e28e3630) - Get-AACAccessToken
        gets it from the sign-in. Running a command needs the
        Microsoft.ContainerService/managedClusters/runCommand/action
        permission (Azure Kubernetes Service Cluster Admin, Contributor) and
        a cluster with run command allowed; it starts a short-lived pod in
        the aks-command namespace and changes nothing else.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string] $ClusterId,

        [switch] $EntraId,

        [ValidateRange(1, 30)]
        [int] $TimeoutMinutes = 5,

        [ValidateRange(1, 60)]
        [int] $PollSeconds = 5
    )

    $result = @{ Totals = @(); Violations = @(); Status = '' }
    $jsonPath = '{range .items[*]}T{"\t"}{.kind}{"\t"}{.metadata.name}{"\t"}{.spec.enforcementAction}{"\t"}{.status.totalViolations}{"\t"}{.metadata.annotations.azure-policy-assignment-id}{"\t"}{.metadata.annotations.azure-policy-definition-reference-id}{"\n"}{range .status.violations[*]}V{"\t"}{.namespace}{"\t"}{.kind}{"\t"}{.name}{"\t"}{.message}{"\n"}{end}{end}'
    $command = "kinds=`$(kubectl api-resources --categories=constraint -o name | paste -sd, -); if [ -z `"`$kinds`" ]; then echo NO_GATEKEEPER; else kubectl get `"`$kinds`" -o jsonpath='$jsonPath'; fi"
    $body = @{ command = $command; context = '' }
    if ($EntraId) { $body.clusterToken = Get-AACAccessToken -Resource '6dae42f8-4368-4678-94ff-3960e28e3630' }
    try {
        $start = Invoke-AACHttp -Method Post -Uri "$ClusterId/runCommand?api-version=2024-02-01" -Body (ConvertTo-Json -InputObject $body -Compress) -ContentType 'application/json'
    }
    catch {
        $result.Status = "Couldn't start the command: $($_.Exception.Message)"
        return $result
    }
    # 200 with the result, or 202 with where to read it.
    $answer = if ($start.Content) { $start.Content | ConvertFrom-Json -AsHashtable -ErrorAction Ignore }
    $location = [string]$(if ($start.Headers) { @($start.Headers['Location'])[0] })
    $deadline = (Get-Date).AddMinutes($TimeoutMinutes)
    while ((-not $answer -or [string]$answer['properties']['provisioningState'] -notin 'Succeeded', 'Failed') -and $location) {
        if ((Get-Date) -gt $deadline) { $result.Status = "The command didn't finish within $TimeoutMinutes minute(s)."; return $result }
        Start-Sleep -Seconds $PollSeconds
        try { $poll = Invoke-AACHttp -Method Get -Uri $location } catch { $result.Status = "Couldn't read the command's result: $($_.Exception.Message)"; return $result }
        if ($poll.Status -eq 200 -and $poll.Content) { $answer = $poll.Content | ConvertFrom-Json -AsHashtable -ErrorAction Ignore }
    }
    $properties = if ($answer -and $answer.Contains('properties')) { $answer['properties'] } else { @{} }
    if ([string]$properties['provisioningState'] -ne 'Succeeded' -or [int]$properties['exitCode'] -ne 0) {
        $result.Status = "The command failed: $(@([string]$properties['reason'], [string]$properties['logs']) | Where-Object { $_ } | Select-Object -First 1)".Trim()
        return $result
    }
    $logs = [string]$properties['logs']
    if ($logs -match 'NO_GATEKEEPER') { $result.Status = 'NoGatekeeper'; return $result }

    $totals = [System.Collections.Generic.List[object]]::new()
    $violations = [System.Collections.Generic.List[object]]::new()
    $current = ''
    foreach ($line in $logs -split '\r?\n') {
        $cells = $line -split "`t"
        if ($cells[0] -eq 'T' -and $cells.Count -ge 5) {
            $current = $cells[1]
            $totals.Add([pscustomobject]@{
                    Kind            = $cells[1]
                    Name            = $cells[2]
                    Action          = $(if ($cells[3]) { $cells[3] } else { 'deny' })
                    TotalViolations = $(if ($cells[4] -match '^\d+$') { [int]$cells[4] } else { 0 })
                    Assignment      = $(if ($cells.Count -gt 5 -and $cells[5]) { ($cells[5] -split '/')[-1] } else { '' })
                    ReferenceId     = $(if ($cells.Count -gt 6) { $cells[6] } else { '' })
                })
        }
        elseif ($cells[0] -eq 'V' -and $cells.Count -ge 5) {
            $violations.Add([pscustomobject]@{ Constraint = $current; Namespace = $(if ($cells[1]) { $cells[1] } else { '(cluster)' }); Object = "$($cells[2])/$($cells[3])"; Message = ($cells[4..($cells.Count - 1)] -join "`t") })
        }
    }
    $result.Totals = $totals.ToArray()
    $result.Violations = $violations.ToArray()
    $result.Status = 'OK'
    $result
}