PSSA-PSCustomUseLiteralPath.psm1

# Copyright: (c) 2019, Jordan Borean (@jborean93) <jborean93@gmail.com>
# MIT License (see LICENSE or https://opensource.org/licenses/MIT)
Function Confirm-ParameterSetMatch {
    <#
    .SYNOPSIS
    Validates whether the list of parameters used will match the parameter set of a cmdlet.
 
    .DESCRIPTION
    Will return whether the parameters used when calling a cmdlet would be accepted by the parameter set specified.
    This will attempt to validate against both named and positional arguments.
 
    .PARAMETER ParameterSet
    The parameter set to validate against.
 
    .PARAMETER UsedParameters
    A list of parameter names, or an entry "positional parameter" that denotes the position of an unnamed parameter.
 
    .EXAMPLE
    $command = Get-Command -Name Get-Item
 
    # With named parameters
    Confirm-ParameterSetMatch -ParameterSet $command.ParameterSets[0] `
        -UsedParameters @("Path", "Force")
 
    # With a positional parameter for -Path
    Confirm-ParameterSetMatch -ParameterSet $command.ParameterSets[0] `
        -UsedParameters @("positional parameter", "Force")
 
    .OUTPUTS
    [System.Boolean]
    #>

    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param (
        [Parameter(Mandatory = $true)]
        [AllowNull()]
        [System.Management.Automation.CommandParameterSetInfo]
        $ParameterSet,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [String[]]
        $UsedParameters
    )

    if ($null -eq $ParameterSet) {
        return $false
    }

    $valid_names = $ParameterSet.Parameters.Name + $ParameterSet.Parameters.Aliases
    $valid_positions = @($ParameterSet.Parameters | Where-Object {
        $null -ne $_.Position -and $_.Position -ne [System.Int32]::MinValue
    } | ForEach-Object { $_.Position })
    $mandatory_params = @{}
    $ParameterSet.Parameters | Where-Object { $_.IsMandatory } | ForEach-Object {
        $mandatory_params.($_.Name) = if ($_.Position -eq [System.Int32]::MinValue) { $null } else { $_.Position }
    }

    for ($i = 0; $i -lt $UsedParameters.Count; $i++) {
        $used_param = $UsedParameters[$i]

        if ($used_param -eq "position parameter") {
            if ($i -notin $valid_positions) {
                return $false
            } elseif ($mandatory_params.ContainsValue($i)) {
                $param_name = $null
                foreach ($kvp in $mandatory_params.GetEnumerator()) {
                    if ($kvp.Value -eq $i) {
                        $param_name = $kvp.Key
                        break
                    }
                }
                $mandatory_params.Remove($param_name)
            }
        } elseif ($used_param -notin $valid_names) {
            return $false
        } elseif ($mandatory_params.ContainsKey($used_param)) {
            $mandatory_params.Remove($used_param) > $null
        }
    }

    return $mandatory_params.Keys.Count -eq 0
}

Function Resolve-SplatVariable {
    <#
    .SYNOPSIS
    Attempts to track splat compatible variables that are defined in an AST.
 
    .DESCRIPTION
    Will analyse the AST passed in and set any hash/dict, array/list variables that are found. It will also try to
    keep track of any keys or entries added to these variables in the relevant hash param passed in.
 
    .PARAMETER Ast
    The AST to inspect and track. Only AssignmentStatementAst and InvokeMemberExpressionAst are analysed, the get are
    skipped.
 
    .PARAMETER HashVars
    A hashtable where the key is the variable name and the value is a list of keys that have been set in the variable.
 
    .PARAMETER ListVars
    A hashtable where the key is the variable name and the value is the current number of entries for the list/array it
    references.
 
    .EXAMPLE
    $hash_vars = @{}
    $list_vars = @{}
    Resolve-SplatVariable -Ast $sb_ast -HashVars $hash_vars -ListVars $list_vars
 
    .NOTES
    This is not perfect but just a best effort attempt to track splat compatible vars used for later analysis.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.Management.Automation.Language.Ast]
        $Ast,

        [Parameter(Mandatory = $true)]
        [Hashtable]
        $HashVars,

        [Parameter(Mandatory = $true)]
        [Hashtable]
        $ListVars
    )

    if ($Ast -is [System.Management.Automation.Language.AssignmentStatementAst] -and
            $Ast.Right -is [System.Management.Automation.Language.CommandExpressionAst]) {
        $implemented_interfaces = $Ast.Right.Expression.StaticType.ImplementedInterfaces | ForEach-Object { $_.FullName }

        if ("System.Collections.IDictionary" -in $implemented_interfaces -and $Ast.Operator -eq "Equals") {
            # Is a splat capable variable as a Hashtable/Dictionary, record the var and keys being set
            $keys = [System.Collections.Generic.List`1[String]]@()
            foreach ($entry in $Ast.Right.Expression.KeyValuePairs) {
                $keys.Add($entry.Item1.Value)
            }
            $HashVars.($Ast.Left.VariablePath.UserPath) = $keys
        } elseif ("System.Collections.IList" -in $implemented_interfaces -and $Ast.Operator -eq "Equals") {
            # Is a splat capable variable as a List/Array, record the var and number of entries
            $var_name = $Ast.Left.VariablePath.UserPath

            if ($Ast.Right.Expression -is [System.Management.Automation.Language.ArrayExpressionAst]) {
                # Standard array definition = @()
                if ($Ast.Right.Expression.SubExpression.Statements.Count -eq 0) {
                    $count = 0
                } else {
                    $value_exp = $Ast.Right.Expression.SubExpression.Statements[0].PipelineElements[0].Expression
                    if ($value_exp -is [System.Management.Automation.Language.ArrayLiteralAst]) {
                        $count = $value_exp.Elements.Count
                    } else {
                        $count = 1
                    }
                }
                $ListVars.$var_name = $count
            } elseif ($Ast.Right.Expression -is [System.Management.Automation.Language.ArrayLiteralAst]) {
                # array = "a", "b"
                $ListVars.$var_name = $Ast.Right.Expression.Elements.Count
            } elseif ($Ast.Right.Expression -is [System.Management.Automation.Language.ConvertExpressionAst]) {
                # ArrayList/List from Array = [System.Collections.ArrayList]@()
                if ($Ast.Right.Expression.Child.SubExpression.Statements.Count -eq 0) {
                    $count = 0
                }  else {
                    $value_exp = $Ast.Right.Expression.Child.SubExpression.Statements[0].PipelineElements[0].Expression
                    if ($value_exp -is [System.Management.Automation.Language.ArrayLiteralAst]) {
                        $count = $value_exp.Elements.Count
                    } else {
                        $count = 1
                    }
                }

                $ListVars.$var_name = $count
            }
        }  elseif ($Ast.Left -is [System.Management.Automation.Language.MemberExpressionAst] -and
                $Ast.Left.Expression -is [System.Management.Automation.Language.VariableExpressionAst]) {
            # $hashtable.Path = ''
            $var_name = $Ast.Left.Expression.VariablePath.UserPath
            if ($HashVars.ContainsKey($var_name)) {
                $properties = $HashVars.$var_name
            } else {
                $properties = [System.Collections.Generic.List`1[String]]@()
            }
            $properties.Add($Ast.Left.Member.Value)
            $HashVars.$var_name = $properties
        } elseif ($Ast.Left -is [System.Management.Automation.Language.IndexExpressionAst]) {
            # $hashtable["Path"] = ''
            $var_name = $Ast.Left.Target.VariablePath.UserPath
            if ($HashVars.ContainsKey($var_name)) {
                $properties = $HashVars.$var_name
            } else {
                $properties = [System.Collections.Generic.List`1[String]]@()
            }
            $properties.Add($Ast.Left.Index.Value)
            $HashVars.$var_name = $properties
        } elseif ($Ast.Operator -eq "PlusEquals") {
            $var_name = $Ast.Left.VariablePath.UserPath
            if ($HashVars.ContainsKey($var_name)) {
                # $hash += @{}
                $target_hash = $HashVars.$var_name

                if ($Ast.Right.Expression -is [System.Management.Automation.Language.HashtableAst]) {
                    $keys = [System.Collections.Generic.List`1[String]]@()
                    foreach ($entry in $Ast.Right.Expression.KeyValuePairs) {
                        $keys.Add($entry.Item1.Value)
                    }
                    $target_hash.AddRange($keys)
                } else {
                    $source_hash = $Ast.Right.Expression.VariablePath.UserPath
                    if ($HashVars.ContainsKey($source_hash)) {
                        $target_hash.AddRange($HashVars.$source_hash)
                    }
                }
            } elseif ($ListVars.ContainsKey($var_name)) {
                # $array += ""
                $count = $ListVars.$var_name

                if ($Ast.Right.Expression -is [System.Management.Automation.Language.ArrayLiteralAst]) {
                    $count += $Ast.Right.Expression.Elements.Count
                } elseif ($Ast.Right.Expression.StaticType.IsArray) {
                    # Adding an array of x elements
                    $count += $Ast.Right.Expression.SubExpression.Statements[0].PipelineElements[0].Expression.Elements.Count
                } else {
                    # Adding single element
                    $count += 1
                }
                $ListVars.$var_name = $count
            }
        }
    } elseif ($Ast -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and
            $Ast.Expression -is [System.Management.Automation.Language.VariableExpressionAst]) {
        # Keeps track of all values that are added and removed in the each hash and list
        $method = $Ast.Member.Value
        $var_name = $Ast.Expression.VariablePath.UserPath

        if (-not ($HashVars.ContainsKey($var_name) -or $ListVars.ContainsKey($var_name))) {
            # A method on a variable that is not one of our known hash/list vars
            return
        }

        if ($method -eq "Add") {
            if ($HashVars.ContainsKey($var_name) -and $Ast.Arguments[0] -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
                $HashVars.$var_name.Add($Ast.Arguments[0].Value)
            } elseif ($ListVars.ContainsKey($var_name)) {
                $ListVars.$var_name += 1
            }
        } elseif ($method -eq "AddRange") {
            if ($ListVars.ContainsKey($var_name) -and $Ast.Arguments[0] -is [System.Management.Automation.Language.VariableExpressionAst]) {
                $added_range_var = $Ast.Arguments[0].VariablePath.UserPath
                if ($ListVars.ContainsKey($added_range_var)) {
                    $ListVars.$var_name += $ListVars.$added_range_var
                }
            }
        } elseif ($method -eq "Insert") {
            if ($Listvars.ContainsKey($var_name)) {
                $ListVars.$var_name += 1
            }
        } elseif ($method -eq "InsertRange") {
            if ($ListVars.ContainsKey($var_name) -and $Ast.Arguments[1] -is [System.Management.Automation.Language.VariableExpressionAst]) {
                $added_range_var = $Ast.Arguments[1].VariablePath.UserPath
                if ($ListVars.ContainsKey($added_range_var)) {
                    $ListVars.$var_name += $ListVars.$added_range_var
                }
            }
        } elseif ($method -eq "Remove") {
            # Cannot do remove from List as we don't know if it contains the value we want to remove
            if ($HashVars.ContainsKey($var_name) -and $Ast.Arguments[0] -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
                $HashVars.$var_name.Remove($Ast.Arguments[0].Value) > $null
            }
        } elseif ($method -eq "RemoveAt") {
            if ($ListVars.ContainsKey($var_name) -and $Ast.Arguments[0] -is [System.Management.Automation.Language.ConstantExpressionAst]) {
                $idx = [int]$Ast.Arguments[0].Value
                if ($idx -lt $ListVars.$var_name) {
                    $ListVars.$var_name -= 1
                }
            }
        } elseif ($method -eq "RemoveRange") {
            if ($ListVars.ContainsKey($var_name) -and $Ast.Arguments[0] -is [System.Management.Automation.Language.ConstantExpressionAst] -and
                    $Ast.Arguments[1] -is [System.Management.Automation.Language.ConstantExpressionAst]) {
                $idx = [int]$Ast.Arguments[0].Value
                $count = [int]$Ast.Arguments[1].Value

                if ($idx -lt $ListVars.$var_name) {
                    $new_count = $ListVars.$var_name - $count
                    $ListVars.$var_name = [Math]::Max(0, $new_count)
                }
            }
        }
    }
}

Function Measure-UseLiteralPath {
    <#
    .SYNOPSIS
    The -LiteralPath parameter should always be used instead of -Path.
 
    .DESCRIPTION
    Using -Path means the PowerShell cmdlet will interpret glob like characters ([, ]) and potentially perform the
    action on multiple objects. In most cases we only want to perform this using the literal path and should be using
    the -LiteralPath parameter to do so.
 
    .EXAMPLE
    Measure-UseLiteralPath -ScriptBlockAst $ScriptBlockAst
 
    .INPUTS
    [System.Management.Automation.Language.ScriptBlockAst]
 
    .OUTPUTS
    [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]
 
    .NOTES
    This is an Ansible built rule and not part of the standard PSScriptAnalyzer project.
    #>

    [CmdletBinding()]
    [OutputType([Microsoft.Windows.Powershell.ScriptAnalyzer.Generic.DiagnosticRecord])]
    Param (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.Management.Automation.Language.ScriptBlockAst]
        $ScriptBlockAst
    )

    # Keep details of a splat compatible variable so we can try and analyse if the splat var uses the -Path parameter
    # hash_vars == @{ variable_name = list of variable keys }
    # list_vars == @{ variable_name = number of entries in list }
    $hash_vars = @{}
    $list_vars = @{}

    [ScriptBlock]$predicate = {
        Param ([System.Management.Automation.Language.Ast]$Ast)

        if ($Ast -isnot [System.Management.Automation.Language.CommandAst]) {
            # While the current AST is not a cmdlet, we want to try and keep track of each variable for splat usages
            try {
                Resolve-SplatVariable -Ast $Ast -HashVars $hash_vars -ListVars $list_vars
            } catch {
                $nl = [System.Environment]::NewLine
                $msg = "Failed to analyze AST for splat compatible variables$nl$nl"
                $msg += $_ | Out-String
                $msg += $nl + $_.ScriptStackTrace
                Write-Warning -Message $msg
            }
            return
        } elseif ($Ast.InvocationOperator -ne "Unknown") {
            # Was invocated with '.' or '&', we will ignore these
            return
        } elseif ($Ast.Parent -is [System.Management.Automation.Language.PipelineAst] -and $Ast.Parent.PipelineElements[0] -ne $Ast) {
            # We don't support analysing pipeline intput into the cmdlet
            return
        }

        # Get the cmdlet info, resolve the alias if it is one
        $command = Get-Command -Name $Ast.GetCommandName() -CommandType Alias, Cmdlet -ErrorAction SilentlyContinue
        if ($null -eq $command) {
            # Not a known/imported cmdlet, cannot check for violations
            return
        }
        if ($command.CommandType -eq "Alias") {
            $command = $command.ResolvedCommand
            if ($command -isnot [System.Management.Automation.CmdletInfo]) {
                # Was not an alias for a cmdlet
                return
            }
        }

        # Ignore the cmdlet if it does not contains both -Path and -LiteralPath
        if (-not ($command.Parameters.ContainsKey("Path") -and $command.Parameters.ContainsKey("LiteralPath"))) {
            return
        }

        # Expand any splatted vars and keep a list of each parameter used
        $used_parameters = [System.Collections.Generic.List`1[Object]]@()
        $param_value = $false
        foreach ($element in $Ast.CommandElements[1..$Ast.CommandElements.Count]) {  # Skip cmdlet declaration
            if ($element -is [System.Management.Automation.Language.VariableExpressionAst] -and $element.Splatted) {
                $var_name = $element.VariablePath.UserPath
                if ($hash_vars.ContainsKey($var_name)) {
                    $parameters = $hash_vars.$var_name
                    foreach ($parameter in $parameters) {
                        $used_parameters.Add($parameter)
                    }
                } elseif ($list_vars.ContainsKey($var_name)) {
                    $count = $list_vars.$var_name
                    for ($i = 0; $i -lt $count; $i++) {
                        $used_parameters.Add("position parameter")
                    }
                }
            } elseif ($element -is [System.Management.Automation.Language.CommandParameterAst]) {
                $used_parameters.Add($element.ParameterName)
                $param_value = $true
            } else {
                if ($param_value) {
                    $param_value = $false
                } else {
                    $used_parameters.Add("position parameter")
                }
            }
        }

        # Check if -LiteralPath is being used directly as a named parameter
        $lpath_aliases = [String[]](@("LiteralPath") + @($command.Parameters.GetEnumerator() | Where-Object {
            $_.Key -eq "LiteralPath" } | ForEach-Object { $_.Value.Aliases }))
        if (@([System.Linq.Enumerable]::Intersect($used_parameters, $lpath_aliases)).Length -gt 0) {
            return
        }

        # Loop through the parameter sets until we find a match
        $parameter_sets = $command.ParameterSets | Sort-Object -Property IsDefault -Descending
        $matched_set = $null
        foreach ($ps in $parameter_sets) {
            if (Confirm-ParameterSetMatch -ParameterSet $ps -UsedParameters $used_parameters) {
                $matched_set = $ps
                break
            }
        }

        if ($null -eq $matched_set) {
            # No parameter sets matched, either we don't have enough info or there is a bug, either way we don't want
            # to flag a false positive
            return
        } elseif ("LiteralPath" -in $matched_set.Parameters.Name) {
            # A -LiteralPath parameter set was matched, no violation
            return
        }

        # Because we matched with a parameter set that used -Path, we need to validate whether we could have used
        # -LiteralPath instead. We do this by getting the parameters actually used by name, converting -Path to
        # -LiteralPath and then comparing that against the valid -LiteralPath parameter sets.
        # Get a list of matched parameters
        $converted_parameters = [System.Collections.Generic.List`1[String]]@()
        for ($i = 0; $i -lt $used_parameters.Count; $i++) {
            $parameter = $used_parameters[$i]

            if ($parameter -eq "position parameter") {
                # If the code used a positional parameter, get the actual param name from the matched set
                $positioned_parameter = $matched_set.Parameters | Where-Object { $_.Position -eq $i }
                $parameter = $positioned_parameter.Name
            }

            if ($parameter -eq "Path") {
                # Because we want to match against -LiteralPath, we need to convert -Path to -LiteralPath
                $parameter = "LiteralPath"
            }
            $converted_parameters.Add($parameter)
        }

        $l_parameter_sets = $command.ParameterSets | Where-Object { "LiteralPath" -in $_.Parameters.Name }
        foreach ($ps in $l_parameter_sets) {
            if (Confirm-ParameterSetMatch -ParameterSet $ps -UsedParameters $converted_parameters) {
                # A parameter set that used -LiteralPath could have been used
                return $true
            }
        }
    }

    try {
        [System.Management.Automation.Language.Ast[]]$violations = $ScriptBlockAst.FindAll($predicate, $true)
        If ($violations.Count -ne 0) {
            foreach ($violation in $violations) {
                [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
                    Extent = $violation.Extent
                    Message = "Use the explicit -LiteralPath parameter name instead of -Path"
                    RuleName = "PSCustomUseLiteralPath"
                    Severity = "Warning"
                }
            }
        }
    } catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}

Export-ModuleMember -Function Measure-UseLiteralPath