Private/Test-AzureChecksSchema.ps1

function Test-AzureChecksSchema {
    <#
    .SYNOPSIS
        Validates AzureChecks.json against its schema.

    .DESCRIPTION
        Performs validation of the AzureChecks.json file to ensure all check
        definitions conform to the expected schema. Validates:
        - Required properties exist
        - Property types are correct
        - Enum values are valid
        - String patterns match
        - Permissions structure is valid

    .PARAMETER Path
        Path to the AzureChecks.json file. Defaults to the module's AzureChecks.json.

    .OUTPUTS
        [PSCustomObject] Validation result with IsValid, Errors, and Warnings properties.

    .EXAMPLE
        Test-AzureChecksSchema
        # Validates the default AzureChecks.json

    .EXAMPLE
        Test-AzureChecksSchema -Path './custom-checks.json'
        # Validates a custom checks file
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [string]$Path
    )

    $ErrorActionPreference = 'Stop'

    # Default to module's AzureChecks.json
    if (-not $Path) {
        $Path = Join-Path $PSScriptRoot '../../AzureChecks.json'
    }

    $errors = [System.Collections.Generic.List[string]]::new()
    $warnings = [System.Collections.Generic.List[string]]::new()

    # Validation constants
    $validServices = @('Entra', 'IAM', 'KeyVault', 'Storage')
    $validSeverities = @('low', 'medium', 'high', 'critical')
    $validCategories = @('encryption', 'identity', 'network', 'logging', 'compliance')
    $validKvDataPlanePerms = @('keys/list', 'keys/get', 'secrets/list', 'secrets/get', 'certificates/list', 'certificates/get')

    # Regex patterns
    $idPattern = '^[a-z]+(_[a-z0-9]+)+$'
    $checkScriptPattern = '^Test-[A-Z][a-zA-Z0-9]+\.ps1$'
    $graphPermPattern = '^[A-Z][a-zA-Z]+\.[A-Z][a-zA-Z]+(\.[A-Z][a-zA-Z]+)?$'
    $armPermPattern = '^Microsoft\.[A-Za-z]+/[a-zA-Z/]+$'

    # Load and parse JSON
    if (-not (Test-Path $Path)) {
        $errors.Add("File not found: $Path")
        return [PSCustomObject]@{
            IsValid  = $false
            Errors   = $errors.ToArray()
            Warnings = $warnings.ToArray()
            Path     = $Path
        }
    }

    try {
        $checksJson = Get-Content -Path $Path -Raw | ConvertFrom-Json
    }
    catch {
        $errors.Add("Invalid JSON: $($_.Exception.Message)")
        return [PSCustomObject]@{
            IsValid  = $false
            Errors   = $errors.ToArray()
            Warnings = $warnings.ToArray()
            Path     = $Path
        }
    }

    # Validate root is array
    if ($checksJson -isnot [array]) {
        $errors.Add("Root element must be an array")
        return [PSCustomObject]@{
            IsValid  = $false
            Errors   = $errors.ToArray()
            Warnings = $warnings.ToArray()
            Path     = $Path
        }
    }

    if ($checksJson.Count -eq 0) {
        $errors.Add("Checks array cannot be empty")
    }

    # Track IDs for uniqueness check
    $seenIds = @{}

    # Validate each check
    for ($i = 0; $i -lt $checksJson.Count; $i++) {
        $check = $checksJson[$i]
        $prefix = "Check[$i]"

        # Get ID early for better error messages
        $checkId = if ($check.id) { $check.id } else { "index $i" }
        $prefix = "Check '$checkId'"

        # Required string properties
        $requiredStrings = @('id', 'service', 'title', 'description', 'risk', 'severity', 'checkScript')
        foreach ($prop in $requiredStrings) {
            if (-not $check.PSObject.Properties[$prop]) {
                $errors.Add("$prefix : Missing required property '$prop'")
            }
            elseif ($check.$prop -isnot [string]) {
                $errors.Add("$prefix : Property '$prop' must be a string")
            }
            elseif ([string]::IsNullOrWhiteSpace($check.$prop)) {
                $errors.Add("$prefix : Property '$prop' cannot be empty")
            }
        }

        # Validate ID format and uniqueness
        if ($check.id) {
            if ($check.id -notmatch $idPattern) {
                $errors.Add("$prefix : ID must be snake_case (e.g., 'entra_security_defaults_enabled')")
            }
            if ($seenIds.ContainsKey($check.id)) {
                $errors.Add("$prefix : Duplicate ID found (first occurrence at index $($seenIds[$check.id]))")
            }
            else {
                $seenIds[$check.id] = $i
            }
        }

        # Validate service enum
        if ($check.service -and $check.service -notin $validServices) {
            $errors.Add("$prefix : Invalid service '$($check.service)'. Must be one of: $($validServices -join ', ')")
        }

        # Validate severity enum
        if ($check.severity -and $check.severity -notin $validSeverities) {
            $errors.Add("$prefix : Invalid severity '$($check.severity)'. Must be one of: $($validSeverities -join ', ')")
        }

        # Validate checkScript pattern
        if ($check.checkScript -and $check.checkScript -notmatch $checkScriptPattern) {
            $errors.Add("$prefix : checkScript must match pattern 'Test-*.ps1' (e.g., 'Test-EntraSecurityDefaultsEnabled.ps1')")
        }

        # Validate title/description length
        if ($check.title -and $check.title.Length -lt 10) {
            $warnings.Add("$prefix : Title is very short (< 10 chars)")
        }
        if ($check.description -and $check.description.Length -lt 20) {
            $warnings.Add("$prefix : Description is very short (< 20 chars)")
        }

        # Validate categories array
        if (-not $check.PSObject.Properties['categories']) {
            $errors.Add("$prefix : Missing required property 'categories'")
        }
        elseif ($check.categories -isnot [array]) {
            $errors.Add("$prefix : Property 'categories' must be an array")
        }
        else {
            foreach ($cat in $check.categories) {
                if ($cat -notin $validCategories) {
                    $errors.Add("$prefix : Invalid category '$cat'. Must be one of: $($validCategories -join ', ')")
                }
            }
        }

        # Validate remediation object
        if (-not $check.PSObject.Properties['remediation']) {
            $errors.Add("$prefix : Missing required property 'remediation'")
        }
        elseif ($check.remediation -isnot [PSCustomObject]) {
            $errors.Add("$prefix : Property 'remediation' must be an object")
        }
        else {
            if (-not $check.remediation.text) {
                $errors.Add("$prefix : remediation.text is required")
            }
            if (-not $check.remediation.PSObject.Properties['url']) {
                $errors.Add("$prefix : remediation.url is required")
            }
        }

        # Validate relatedUrl exists (can be empty string)
        if (-not $check.PSObject.Properties['relatedUrl']) {
            $errors.Add("$prefix : Missing required property 'relatedUrl'")
        }

        # Validate dependsOn array
        if (-not $check.PSObject.Properties['dependsOn']) {
            $errors.Add("$prefix : Missing required property 'dependsOn'")
        }
        elseif ($check.dependsOn -isnot [array]) {
            $errors.Add("$prefix : Property 'dependsOn' must be an array")
        }
        else {
            foreach ($dep in $check.dependsOn) {
                if ($dep -notmatch $idPattern) {
                    $errors.Add("$prefix : dependsOn value '$dep' must be a valid check ID (snake_case)")
                }
            }
        }

        # Validate permissions object
        if (-not $check.PSObject.Properties['permissions']) {
            $errors.Add("$prefix : Missing required property 'permissions'")
        }
        elseif ($check.permissions -isnot [PSCustomObject]) {
            $errors.Add("$prefix : Property 'permissions' must be an object")
        }
        else {
            $permProps = $check.permissions.PSObject.Properties.Name
            $validPermProps = @('graph', 'arm', 'keyvaultDataPlane')

            # Must have at least one permission type
            if ($permProps.Count -eq 0) {
                $errors.Add("$prefix : permissions must have at least one of: $($validPermProps -join ', ')")
            }

            # Check for invalid permission types
            foreach ($prop in $permProps) {
                if ($prop -notin $validPermProps) {
                    $errors.Add("$prefix : Invalid permission type '$prop'. Must be one of: $($validPermProps -join ', ')")
                }
            }

            # Validate graph permissions
            if ($check.permissions.graph) {
                if ($check.permissions.graph -isnot [array]) {
                    $errors.Add("$prefix : permissions.graph must be an array")
                }
                else {
                    foreach ($perm in $check.permissions.graph) {
                        if ($perm -notmatch $graphPermPattern) {
                            $errors.Add("$prefix : Invalid Graph permission '$perm'. Expected format like 'Policy.Read.All'")
                        }
                    }
                }
            }

            # Validate ARM permissions
            if ($check.permissions.arm) {
                if ($check.permissions.arm -isnot [array]) {
                    $errors.Add("$prefix : permissions.arm must be an array")
                }
                else {
                    foreach ($perm in $check.permissions.arm) {
                        if ($perm -notmatch $armPermPattern) {
                            $errors.Add("$prefix : Invalid ARM permission '$perm'. Expected format like 'Microsoft.Storage/storageAccounts/read'")
                        }
                    }
                }
            }

            # Validate Key Vault data plane permissions
            if ($check.permissions.keyvaultDataPlane) {
                if ($check.permissions.keyvaultDataPlane -isnot [array]) {
                    $errors.Add("$prefix : permissions.keyvaultDataPlane must be an array")
                }
                else {
                    foreach ($perm in $check.permissions.keyvaultDataPlane) {
                        if ($perm -notin $validKvDataPlanePerms) {
                            $errors.Add("$prefix : Invalid keyvaultDataPlane permission '$perm'. Must be one of: $($validKvDataPlanePerms -join ', ')")
                        }
                    }
                }
            }
        }
    }

    # Return validation result
    [PSCustomObject]@{
        IsValid    = $errors.Count -eq 0
        Errors     = $errors.ToArray()
        Warnings   = $warnings.ToArray()
        Path       = $Path
        CheckCount = $checksJson.Count
    }
}