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 $checksJson = $null $earlyExit = $false if (-not (Test-Path $Path)) { $errors.Add("File not found: $Path") $earlyExit = $true } if (-not $earlyExit) { try { $checksJson = Get-Content -Path $Path -Raw | ConvertFrom-Json } catch { $errors.Add("Invalid JSON: $($_.Exception.Message)") $earlyExit = $true } } # Validate root is array if (-not $earlyExit -and $checksJson -isnot [array]) { $errors.Add("Root element must be an array") $earlyExit = $true } if ($earlyExit) { [PSCustomObject]@{ IsValid = $false Errors = $errors.ToArray() Warnings = $warnings.ToArray() Path = $Path } } else { 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 } } } |