Private/Export/Test-ExportExpression.ps1

<#
    Copyright (c) 2025-2026 Robert Faddes
    SPDX-License-Identifier: MPL-2.0
#>

<#
.SYNOPSIS
    Validates an expression string for safe use in export transforms.
.DESCRIPTION
    Applies layered security validation to an expression string before it is converted
    to a scriptblock by ConvertTo-ExportProperty.
 
    Layer 1 — Length gate: rejects expressions longer than 500 characters.
    Layer 2 — Character whitelist: rejects expressions containing characters outside a
              safe ASCII set (letters, digits, common punctuation, comparison operators).
    Layer 3 — AST validation: parses the expression with the PowerShell parser and walks
              the entire AST tree. Only whitelisted node types are permitted. This blocks
              cmdlet/function calls, .NET type access, assignment statements, redirections,
              and scriptblock literals.
    Layer 4 — Variable restriction: only $_, $null, $true, and $false are permitted.
              Access to arbitrary variables ($env:, $Host, $Error, etc.) is blocked.
 
    Returns $true if the expression passes all layers, $false otherwise.
    Writes a verbose message describing the first failing layer.
 
    This function does not create or execute a scriptblock. It only validates.
#>

function Test-ExportExpression {
    [CmdletBinding()]
    [OutputType([bool])]
    param (

        [Parameter(Mandatory = $true)]
        [string]
        $Expression,

        [Parameter(Mandatory = $true)]
        [string]
        $PropertyName

    )

    # Layer 1: Length gate
    if ($Expression.Length -gt 500) {

        Write-Warning "Skipping transform property '$PropertyName': expression exceeds 500 character limit ($($Expression.Length) characters)"
        return $false

    }

    # Layer 2: ASCII character whitelist (fast gate before parsing)
    # Allows: letters, digits, underscore, whitespace, dot, $, (), {}, [], quotes,
    # backtick, comma, hyphen, colon, pipe, /, semicolon, =, !, <, >, ?, #, @, +, *, %
    if ($Expression -notmatch '^[A-Za-z0-9_\s\.\$\(\)\{\}\[\]''\"`,\-\:\|\/\;\=\!\<\>\?\#\@\+\*\%]+$') {

        Write-Warning "Skipping transform property '$PropertyName': expression contains disallowed characters"
        return $false

    }

    # Layer 3: AST validation — parse and walk the tree
    $ParseErrors = $null
    $Tokens = $null
    $Ast = [System.Management.Automation.Language.Parser]::ParseInput(
        $Expression,
        [ref]$Tokens,
        [ref]$ParseErrors
    )

    if ($ParseErrors.Count -gt 0) {

        Write-Warning "Skipping transform property '$PropertyName': expression has syntax errors ($($ParseErrors[0].Message))"
        return $false

    }

    # Whitelisted AST node types — only structural, access, and conditional nodes
    $AllowedNodeTypes = @(
        'ScriptBlockAst'
        'NamedBlockAst'
        'StatementBlockAst'
        'PipelineAst'
        'CommandExpressionAst'
        'MemberExpressionAst'
        'InvokeMemberExpressionAst'
        'VariableExpressionAst'
        'StringConstantExpressionAst'
        'ExpandableStringExpressionAst'
        'ConstantExpressionAst'
        'BinaryExpressionAst'
        'UnaryExpressionAst'
        'IfStatementAst'
        'ParenExpressionAst'
        'SubExpressionAst'
        'ArrayExpressionAst'
        'ArrayLiteralAst'
        'IndexExpressionAst'
        'HashtableAst'
        'HashtablePairAst'
        'TernaryExpressionAst'
    )

    # Allowed variable names — only pipeline object and PowerShell built-in constants
    $AllowedVariables = @('_', 'null', 'true', 'false')

    $AstNodesAll = $Ast.FindAll({$true}, $true)

    foreach ($Node in $AstNodesAll) {

        $NodeTypeName = $Node.GetType().Name

        if ($NodeTypeName -notin $AllowedNodeTypes) {

            Write-Warning "Skipping transform property '$PropertyName': expression contains disallowed construct '$NodeTypeName'"
            return $false

        }

        # Layer 4: Variable restriction
        if ($Node -is [System.Management.Automation.Language.VariableExpressionAst]) {

            if ($Node.VariablePath.UserPath -notin $AllowedVariables) {

                Write-Warning "Skipping transform property '$PropertyName': expression references disallowed variable '`$$($Node.VariablePath.UserPath)'"
                return $false

            }
        }
    }

    Write-Debug "Expression validated for '$PropertyName': $Expression"
    return $true
    
}

# SIG # Begin signature block
# MIIF+wYJKoZIhvcNAQcCoIIF7DCCBegCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDkhLNfsQrWZls2
# qaOLvrg4tX8Eq3HXuwfiMRzDDYil+aCCA04wggNKMIICMqADAgECAhB464iXHfI6
# gksEkDDTyrNsMA0GCSqGSIb3DQEBCwUAMD0xFjAUBgNVBAoMDVJvYmVydCBGYWRk
# ZXMxIzAhBgNVBAMMGkRhdHRvUk1NLkNvcmUgQ29kZSBTaWduaW5nMB4XDTI2MDMz
# MTAwMTMzMFoXDTI4MDMzMTAwMjMzMFowPTEWMBQGA1UECgwNUm9iZXJ0IEZhZGRl
# czEjMCEGA1UEAwwaRGF0dG9STU0uQ29yZSBDb2RlIFNpZ25pbmcwggEiMA0GCSqG
# SIb3DQEBAQUAA4IBDwAwggEKAoIBAQChn1EpMYQgl1RgWzQj2+wp2mvdfb3UsaBS
# nxEVGoQ0gj96tJ2MHAF7zsITdUjwaflKS1vE6wAlOg5EI1V79tJCMxzM0bFpOdR1
# L5F2HE/ovIAKNkHxFUF5qWU8vVeAsOViFQ4yhHpzLen0WLF6vhmc9eH23dLQy5fy
# tELZQEc2WbQFa4HMAitP/P9kHAu6CUx5s4woLIOyyR06jkr3l9vk0sxcbCxx7+dF
# RrsSLyPYPH+bUAB8+a0hs+6qCeteBuUfLvGzpMhpzKAsY82WZ3Rd9X38i32dYj+y
# dYx+nx+UEMDLjDJrZgnVa8as4RojqVLcEns5yb/XTjLxDc58VatdAgMBAAGjRjBE
# MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQU
# H+B0vf97dYXqdUX1YMcWhFsY6fcwDQYJKoZIhvcNAQELBQADggEBAJmD4EEGNmcD
# 1JtFoRGxuLJaTHxDwBsjqcRQRE1VPZNGaiwIm8oSQdHVjQg0oIyK7SEb02cs6n6Y
# NZbwf7B7WZJ4aKYbcoLug1k1x9SoqwBmfElECeJTKXf6dkRRNmrAodpGCixR4wMH
# KXqwqP5F+5j7bdnQPiIVXuMesxc4tktz362ysph1bqKjDQSCBpwi0glEIH7bv5Ms
# Ey9Gl3fe+vYC5W06d2LYVebEfm9+7766hsOgpdDVgdtnN+e6uwIJjG/6PTG6TMDP
# y+pr5K6LyUVYJYcWWUTZRBqqwBHiLGekPbxrjEVfxUY32Pq4QfLzUH5hhUCAk4HN
# XpF9pOzFLMUxggIDMIIB/wIBATBRMD0xFjAUBgNVBAoMDVJvYmVydCBGYWRkZXMx
# IzAhBgNVBAMMGkRhdHRvUk1NLkNvcmUgQ29kZSBTaWduaW5nAhB464iXHfI6gksE
# kDDTyrNsMA0GCWCGSAFlAwQCAQUAoIGEMBgGCisGAQQBgjcCAQwxCjAIoAKAAKEC
# gAAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwG
# CisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIC7B4zswZGCAAaIb7a85okqHmfma
# lJSyKvno08WAfNPjMA0GCSqGSIb3DQEBAQUABIIBABDAchoGrEQ2jSmIEfUgv2BV
# nwD4MCnD8R2NrT9GQjWlUs5l3EsC75r2Qi2PEe8Si5Yr9koqju2By+il4CL/RKHs
# 9isJUkpVWFaSxAhrDdrXp8MJVoywafw6XccVJNX/ZWadC3sc1yTYJIyTcEAHd5ar
# rR3v4c2+51ZLVmvTUv9zKNBDw8fr5CGlTJ4jspR8+UkvlhNEFfv59zVg7M7S056h
# 6vRaLG3Dg7ua1GVZRC3nZ9oPIvf6RnkbtrnTGcO6MWwlGVKIWiItEpKF2PGG9XOi
# Y9iKEyFH9UP9IzH0zKuRwCQL8V40ib57Hv4zCY675zIa4WK6Ems1WIrkNEt60yo=
# SIG # End signature block