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 |