Private/Assert-EFBaseline.ps1

function Assert-EFBaseline {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Baseline
    )

    $name = [string](Get-EFPropertyValue -InputObject $Baseline -Name 'Name')
    $version = [string](Get-EFPropertyValue -InputObject $Baseline -Name 'Version')
    $description = [string](Get-EFPropertyValue -InputObject $Baseline -Name 'Description')
    $controls = @(Get-EFPropertyValue -InputObject $Baseline -Name 'Controls')
    $assertSafeText = {
        param(
            [AllowNull()]
            [object]$Value,
            [Parameter(Mandatory)]
            [string]$Label
        )

        if ($null -ne $Value -and [string]$Value -match '[\x00-\x1F\x7F]') {
            throw [System.IO.InvalidDataException]::new("$Label must not contain control characters.")
        }
    }

    if ([string]::IsNullOrWhiteSpace($name)) {
        throw [System.IO.InvalidDataException]::new('A baseline must define a non-empty Name.')
    }
    if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') {
        throw [System.IO.InvalidDataException]::new("Baseline Name '$name' must contain only letters, numbers, dots, underscores, and hyphens.")
    }
    if ([string]::IsNullOrWhiteSpace($version)) {
        throw [System.IO.InvalidDataException]::new("Baseline '$name' must define a Version.")
    }
    if ($version -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$') {
        throw [System.IO.InvalidDataException]::new("Baseline '$name' Version '$version' is not a semantic version.")
    }
    if ([string]::IsNullOrWhiteSpace($description)) {
        throw [System.IO.InvalidDataException]::new("Baseline '$name' must define a non-empty Description.")
    }
    & $assertSafeText $name 'Baseline Name'
    & $assertSafeText $version "Baseline '$name' Version"
    & $assertSafeText $description "Baseline '$name' Description"
    if ($controls.Count -eq 0) {
        throw [System.IO.InvalidDataException]::new("Baseline '$name' must contain at least one control.")
    }

    $supportedTypes = @(
        'Registry', 'Service', 'FirewallProfile', 'Defender', 'WindowsOptionalFeature',
        'BitLocker', 'SecureBoot', 'Tpm'
    )
    $ids = @{}
    foreach ($control in $controls) {
        $id = [string](Get-EFPropertyValue -InputObject $control -Name 'Id')
        $type = [string](Get-EFPropertyValue -InputObject $control -Name 'Type')
        if ([string]::IsNullOrWhiteSpace($id)) {
            throw [System.IO.InvalidDataException]::new("Baseline '$name' has a control without an Id.")
        }
        if ($id -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') {
            throw [System.IO.InvalidDataException]::new("Control Id '$id' must contain only letters, numbers, dots, underscores, and hyphens.")
        }
        if ($ids.ContainsKey($id)) {
            throw [System.IO.InvalidDataException]::new("Baseline '$name' contains duplicate control Id '$id'.")
        }
        $ids[$id] = $true
        if ($type -notin $supportedTypes) {
            throw [System.IO.InvalidDataException]::new("Control '$id' uses unsupported Type '$type'.")
        }

        $title = [string](Get-EFPropertyValue -InputObject $control -Name 'Title')
        $severity = [string](Get-EFPropertyValue -InputObject $control -Name 'Severity')
        if ([string]::IsNullOrWhiteSpace($title)) {
            throw [System.IO.InvalidDataException]::new("Control '$id' must define a Title.")
        }
        if ($severity -notin @('Low', 'Medium', 'High', 'Critical')) {
            throw [System.IO.InvalidDataException]::new("Control '$id' has invalid Severity '$severity'.")
        }
        foreach ($textProperty in @(
            'Id', 'Title', 'Description', 'WhyItMatters', 'HowChecked', 'WhatWouldChange',
            'ManualAction', 'SafetyNotes', 'RecoveryGuidance', 'Type', 'Severity', 'Path',
            'ValueName', 'Name', 'Property', 'StartupType', 'Status', 'MountPoint'
        )) {
            if (Test-EFPropertyPresent -InputObject $control -Name $textProperty) {
                & $assertSafeText (Get-EFPropertyValue -InputObject $control -Name $textProperty) "Control '$id' $textProperty"
            }
        }
        if (-not (Test-EFPropertyPresent -InputObject $control -Name 'DesiredValue')) {
            throw [System.IO.InvalidDataException]::new("Control '$id' must define DesiredValue.")
        }
        if (-not (Test-EFPropertyPresent -InputObject $control -Name 'Remediable')) {
            throw [System.IO.InvalidDataException]::new("Control '$id' must explicitly define Remediable.")
        }
        $remediableValue = Get-EFPropertyValue -InputObject $control -Name 'Remediable'
        if ($remediableValue -isnot [bool]) {
            $remediableType = if ($null -eq $remediableValue) { 'null' } else { $remediableValue.GetType().Name }
            throw [System.IO.InvalidDataException]::new("Control '$id' Remediable must be a JSON Boolean, not '$remediableType'.")
        }
        if ((Test-EFPropertyPresent -InputObject $control -Name 'RequiresReboot') -and
            (Get-EFPropertyValue -InputObject $control -Name 'RequiresReboot') -isnot [bool]) {
            throw [System.IO.InvalidDataException]::new("Control '$id' RequiresReboot must be a JSON Boolean.")
        }
        if ([bool]$remediableValue -and $type -notin @('Registry', 'Service', 'FirewallProfile', 'Defender', 'WindowsOptionalFeature')) {
            throw [System.IO.InvalidDataException]::new("Control '$id' of type '$type' is audit-only and cannot set Remediable to true.")
        }

        $requiredByType = switch ($type) {
            'Registry'               { @('Path', 'ValueName') }
            'Service'                { @('Name') }
            'FirewallProfile'        { @('Name') }
            'Defender'               { @('Property') }
            'WindowsOptionalFeature' { @('Name') }
            default                  { @() }
        }
        foreach ($requiredProperty in $requiredByType) {
            if ([string]::IsNullOrWhiteSpace([string](Get-EFPropertyValue -InputObject $control -Name $requiredProperty))) {
                throw [System.IO.InvalidDataException]::new("Control '$id' of type '$type' must define $requiredProperty.")
            }
        }

        if ($type -eq 'Service') {
            $startupType = [string](Get-EFPropertyValue -InputObject $control -Name 'StartupType')
            $serviceStatus = [string](Get-EFPropertyValue -InputObject $control -Name 'Status')
            if ([string]::IsNullOrWhiteSpace($startupType) -and [string]::IsNullOrWhiteSpace($serviceStatus)) {
                throw [System.IO.InvalidDataException]::new("Service control '$id' must define StartupType, Status, or both.")
            }
            if (-not [string]::IsNullOrWhiteSpace($startupType) -and $startupType -notin @('Automatic', 'Manual', 'Disabled')) {
                throw [System.IO.InvalidDataException]::new("Service control '$id' has invalid StartupType '$startupType'.")
            }
            if (-not [string]::IsNullOrWhiteSpace($serviceStatus) -and $serviceStatus -notin @('Running', 'Stopped')) {
                throw [System.IO.InvalidDataException]::new("Service control '$id' has invalid Status '$serviceStatus'.")
            }
        }

        $desiredValue = Get-EFPropertyValue -InputObject $control -Name 'DesiredValue'
        if ($type -in @('FirewallProfile', 'SecureBoot') -and $desiredValue -isnot [bool]) {
            throw [System.IO.InvalidDataException]::new("Control '$id' DesiredValue must be a JSON Boolean for type '$type'.")
        }
        if ($type -eq 'Defender') {
            $defenderProperty = [string](Get-EFPropertyValue -InputObject $control -Name 'Property')
            if ([bool]$remediableValue -and $defenderProperty -ne 'RealTimeProtectionEnabled') {
                throw [System.IO.InvalidDataException]::new("Defender control '$id' can only be remediated automatically for RealTimeProtectionEnabled.")
            }
            if ($desiredValue -isnot [bool]) {
                throw [System.IO.InvalidDataException]::new("Defender control '$id' DesiredValue must be a JSON Boolean.")
            }
        }
        if ($type -eq 'WindowsOptionalFeature' -and [string]$desiredValue -notin @('Enabled', 'Disabled')) {
            throw [System.IO.InvalidDataException]::new("Optional feature control '$id' DesiredValue must be Enabled or Disabled.")
        }
        if ($type -eq 'Registry') {
            $registryPath = [string](Get-EFPropertyValue -InputObject $control -Name 'Path')
            if ($registryPath -notmatch '^(?i)(?:HKLM|HKCU):\\.+$') {
                throw [System.IO.InvalidDataException]::new(
                    "Registry control '$id' Path must use an HKLM:\ or HKCU:\ Registry provider path."
                )
            }
            $valueType = [string](Get-EFPropertyValue -InputObject $control -Name 'ValueType' -Default 'String')
            if ($valueType -notin @('String', 'ExpandString', 'Binary', 'DWord', 'MultiString', 'QWord')) {
                throw [System.IO.InvalidDataException]::new("Registry control '$id' has unsupported ValueType '$valueType'.")
            }
            $isNumeric = $desiredValue -is [byte] -or $desiredValue -is [sbyte] -or
                $desiredValue -is [int16] -or $desiredValue -is [uint16] -or
                $desiredValue -is [int32] -or $desiredValue -is [uint32] -or
                $desiredValue -is [int64] -or $desiredValue -is [uint64] -or
                $desiredValue -is [single] -or $desiredValue -is [double] -or $desiredValue -is [decimal]
            if ($valueType -in @('DWord', 'QWord') -and -not $isNumeric) {
                throw [System.IO.InvalidDataException]::new("Registry control '$id' DesiredValue must be numeric for ValueType '$valueType'.")
            }
        }
        if ($type -eq 'BitLocker' -and [string]$desiredValue -ne 'On') {
            throw [System.IO.InvalidDataException]::new("BitLocker control '$id' DesiredValue must be On.")
        }
        if ($type -eq 'Tpm') {
            foreach ($tpmProperty in @('TpmPresent', 'TpmReady')) {
                if (-not (Test-EFPropertyPresent -InputObject $desiredValue -Name $tpmProperty) -or
                    (Get-EFPropertyValue -InputObject $desiredValue -Name $tpmProperty) -isnot [bool]) {
                    throw [System.IO.InvalidDataException]::new("TPM control '$id' DesiredValue.$tpmProperty must be a JSON Boolean.")
                }
            }
        }
    }
}